Skip to content

[1/9] Media-api enhancement: searchAfter cursor navigation endpoint - #4849

Open
paperboyo wants to merge 5 commits into
mainfrom
mk-api-1of9-searchAfter
Open

[1/9] Media-api enhancement: searchAfter cursor navigation endpoint#4849
paperboyo wants to merge 5 commits into
mainfrom
mk-api-1of9-searchAfter

Conversation

@paperboyo

@paperboyo paperboyo commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Co-authored by Claude. Description written by me, with Claude, hopefully useful, narrative below.

What does this change?

This is the first of nine planned additions to media-api to support experimental frontend which currently talks to ES directly from me laptop. The intention is for those to not have any effect on production app. Changes go beyond additive only where it seems absurd to not make small amendments and only after verifying no ill effects for real app. These are always noted and up for debate (as is all the rest of it, of course).

How should a reviewer test this change?

Mercilessly. This includes tests, which together with existing tests, pass cleanly. To verify on working prototype, run local Grid with --use-TEST and ALSO run Kupua with --use-media-api (it passes prototype’s searchAfter requests via this modified, local, media-api).

How can success be measured?

It’s still up for debate. We may never measure it, but it might be silly not to try to. Unless, it would be silly to try. Also: “Try not. Do, or do not. There is no try”.

Who should look at this?

@twrichards (and anyone and everyone else who feels like)

Tested? Documented?

  • locally by committer
  • locally by Guardian reviewer
  • on the Guardian's TEST environment
  • relevant documentation added or amended (if needed)

Here is 🤖 narrative (and gazillions of “docs” here):

Why

Kupua (the in-development Grid frontend prototype) needs cursor-based pagination
(search-after) to let users scroll through millions of images without the 100,000-hit ES
offset wall. Currently kupua hits ES directly. This PR adds the server-side half so that
traffic can eventually be routed through media-api instead.

This is the first of ~9 planned media-api extensions to support kupua (PIT snapshots,
multi-get, and others). The conventions established here — POST+JSON for cursor endpoints,
shared hitToImageEntity/SearchParamsBody building blocks — will apply to the rest, so
it's worth settling any disagreement now rather than per-PR later.

This is a net-new route with no existing callers — it carries zero traffic today and has
zero blast radius on current behaviour until kupua is switched to use it.

What

New route: POST /images/search-after

sorts.scalareverseSorts, jsonToSort (flat + nested-object sort clause deserialisation),
orderOf/sortModeOf helpers.

ElasticSearchModel.scalaSearchAfterParams, SearchAfterRawResults, SearchParamsBody
(parses the POST body: query, date range, label/uploader/category/collections/has/is filters,
hasRightsAcquired, syndicationStatus, orderBy, countAll, page size/offset). payType is
always None — not sent by kupua (disabled in its UI; cost filtering is a plain free/non-free
boolean there).

ElasticSearch.scalasearchAfter(): reuses buildFilterOpt, applies null-zone
strip/remap on seek-to-end cursors, validates cursor length, fans into a PIT branch (bypasses
prepareSearch migration dedup filter) or a plain branch. _source projection is
schema-derived at startup (reflection on Image fields minus {embedding, originalMetadata, fileMetadata} plus fieldAliasConfigs paths) — cuts payload from ~1.7 MB to ~370 KB per page.
resolveSearchAfterHit strips the drop-set from a copy of _source before validate[Image]
(avoids JsError when field aliases touch fileMetadata leaves) while keeping the full source
for alias extraction.

QueryBuilder.scala — two additions: dateAddedToCollection filter widens to also match
"-dateAddedToCollection" (the ascending token kupua sends; see the Kahuna note below). Also a
new hasRightsAcquiredFilter (syndicationRights.rights.acquired term query) — closes a parity
gap where kupua's direct-ES path already applied this filter but it was silently dropped when
routed via media-api.

MediaApi.scalahitToImageEntity lifted to private method. searchAfterImages action
enriches each hit via the lifted hitToImageEntity (→ imageResponse.create), with a typed
SearchAfterResponse case class + OWrites. (A lean one-pass writer, createForBrowse, was
prototyped and measured but reverted — it is not in this PR. See the Performance note below.)

conf/routesPOST /images/search-after before GET /images/:id.

[EDIT: amended post-Copilot review] ElasticSearchTest.scala / ElasticSearchTestBase.scala — 23 integration tests, and a new SortsTest.scala — 9 unit tests (no Docker) for the sort-clause deserialiser:
forward/reverse cursor pagination, null-zone round-trip, seekToEnd+null-zone,
cursor-mismatch→ 422, dateAddedToCollection filter both orders (cursor path),
dateAddedToCollection sort both orders (Kahuna search() path), fieldAliases projection,
isPotentiallyGraphic via fieldAlias.

One small, intentional improvement to the media-api sort contract

This PR makes the -dateAddedToCollection (ascending) sort token work when calling
GET /images directly. It previously didn't: the token fell through to a fieldSort on
an unmapped field with no unmappedType, so ES errored / no-op'd. Two coupled pieces:

  • QueryBuilder.scala — the dateAddedToCollection pathHierarchy filter now also fires for
    the negated token (kupua needs this; its ascending collection sort is meaningless without
    the collection filter).
  • sorts.scala + ElasticSearch.scala — added dateAddedToCollectionAscending (ASC,
    unmappedType("date")) and the matching search() case, so the sort actually applies
    instead of erroring.

Note on Kahuna: Kahuna's getOrder() function in media-api.js transforms any
unrecognised orderBy token to -uploadTime before the request reaches media-api. So
-dateAddedToCollection is stripped by the JS layer and never arrives at this code path
from the Kahuna UI (even via manual URL editing). This change benefits direct API
callers
(curl, REST clients, future integrations). We left the server-side contract
more correct; the Kahuna JS is a separate concern.

Performance note

Each hit is enriched via the lifted hitToImageEntityimageResponse.create — the same
path Kahuna's GET /images uses. On a fast production ES link the dominant per-page cost is
this Argo envelope build (~55 ms/page, measured): imageResponse.create runs a ~12-step
JsObject.transform chain and presigns S3 URLs per hit. A lean one-pass writer
(createForBrowse) was prototyped and measured (~42% envelope reduction) but reverted
it is not in this PR. It is the main remaining server-side optimisation and is worth building
before this endpoint carries production browse traffic.

The ~3× slowness versus direct-ES that you may see in local dev is a separate, dev-only
artefact
: the elastic4s client → ES leg is uncompressed (~5× the bytes over the same SSH
tunnel), which does not apply on the production same-VPC link. Independent confirmation: #4784
(gzip compression on the ES REST client, approved, not yet merged) measured the same shape —
a 2.4–3.2× win over the SSH tunnel, but no regression (±14 ms, within noise) on TEST's intra-VPC
link. Once #4784 merges, this local-dev artefact goes away for every endpoint, not just this one.

Two decisions for team consideration

1. POST for a read endpoint. The cursor + sort clause + filter set is too large for a query
string. POST with application/json body is the pragmatic choice. Play's CSRF filter does not
check application/json by default, so no CSRF config changes were needed. Worth agreeing this
as the convention for future cursor/filter-heavy endpoints.

2. auth.async(parse.json). The action uses auth.async with the parse.json body parser
combinator — first use of this pattern in media-api. If the team prefers a different shape for
authenticated JSON endpoints, this is the place to align.

Neither requires a code change here — just noting for review.

New POST /images/search-after endpoint for cursor-based (search_after)
pagination.

--- What's here ---

sorts.scala
  reverseSorts (flip asc/desc on a sort seq), jsonToSort (deserialise flat and
  nested-object ES sort clause entries from JSON), orderOf/sortModeOf helpers.
  dateAddedToCollectionAscending added alongside the existing Descending — see
  "N-1 note" below.

ElasticSearchModel.scala
  SearchAfterParams, SearchAfterRawResults case classes. SearchParamsBody with
  fromJson (parses POST body into SearchParams: query, date range, filters,
  hasRightsAcquired, syndicationStatus, orderBy, countAll, page size/offset).
  payType not parsed (disabled in Kahuna).
  hasRightsAcquired also added to SearchParams + SearchParams.apply(request) so
  it is available as a GET query param too.

ElasticSearch.scala
  searchAfter() method: buildFilterOpt reuse, null-zone strip/remap on
  seekToEnd cursors, cursor-length validation, PIT branch (bypasses prepareSearch
  migration dedup filter — correctness requirement), plain branch. _source
  projection schema-derived at startup (reflection on Image fields minus
  {embedding, originalMetadata, fileMetadata} plus fieldAliasConfigs paths;
  cuts payload ~2.1 MB → ~370 KB/page). resolveSearchAfterHit strips drop-fields
  from a copy of _source before validate[Image] while keeping the full source for
  alias extraction (avoids JsError when field aliases touch fileMetadata leaves).

QueryBuilder.scala
  dateAddedToCollection pathHierarchy filter widened to also match
  "-dateAddedToCollection" (ascending token).

MediaApi.scala
  hitToImageEntity lifted to private method. searchAfterImages action:
  auth.async(parse.json), parses body via SearchParamsBody.fromJson, calls
  searchAfter, serialises response via typed SearchAfterResponse case class +
  OWrites.

conf/routes
  POST /images/search-after inserted before GET /images/:id.

--- Two decisions for team consideration ---

1. POST for a read endpoint — cursor + sort clause + filters are too large for
   a query string; POST with application/json is the pragmatic choice. Play's
   CSRF filter does not check application/json by default.

2. auth.async(parse.json) — first use of this body-parser combinator pattern
   in media-api. Worth aligning if the team has a preferred alternative.

--- N-1 note: dateAddedToCollectionAscending ---

The "-dateAddedToCollection" token previously fell through to a fieldSort on an
unmapped field with no unmappedType, causing an ES error. dateAddedToCollectionAscending
(ASC, unmappedType "date") fixes this for direct API callers. Note: Kahuna's
getOrder() in media-api.js transforms unrecognised orderBy tokens to -uploadTime
before the request reaches this code, so this change has no effect on the Kahuna
UI — it benefits direct API calls only. The filter half (QueryBuilder widening)
was needed regardless; the sort half makes the server-side contract coherent
rather than leaving a half-state (filter fires, sort silently ignored).
16 new integration tests: forward/reverse cursor pagination, null-zone
round-trip, seekToEnd+null-zone crash guard, cursor-mismatch → 422,
dateAddedToCollection filter both orders (cursor path), dateAddedToCollection
sort both orders (Kahuna search() path), fieldAliases projection, silent
fieldAlias (isPotentiallyGraphic via fileMetadata.xmp.pur:adultContentWarning).
graphic-image-1 fixture added to ElasticSearchTestBase.
@paperboyo paperboyo added the feature Departmental tracking: work on a new feature label Jul 29, 2026
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

paperboyo added a commit that referenced this pull request Aug 1, 2026
@paperboyo
paperboyo marked this pull request as ready for review August 17, 2026 10:44
@paperboyo
paperboyo requested a review from a team as a code owner August 17, 2026 10:44
@paperboyo
paperboyo requested a balanced review from Copilot August 17, 2026 10:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds cursor-based Elasticsearch pagination to media-api for the Kupua frontend.

Changes:

  • Adds authenticated POST /images/search-after.
  • Implements cursor, reverse, PIT, null-zone, filtering, and source projection support.
  • Adds integration coverage and collection-sort improvements.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
media-api/conf/routes Registers the search-after endpoint.
media-api/app/controllers/MediaApi.scala Parses requests and builds responses.
media-api/app/lib/elasticsearch/ElasticSearch.scala Implements cursor-based searching.
media-api/app/lib/elasticsearch/ElasticSearchModel.scala Adds request and response models.
media-api/app/lib/elasticsearch/QueryBuilder.scala Adds rights and collection filtering.
media-api/app/lib/elasticsearch/sorts.scala Adds sort parsing and reversal.
media-api/test/lib/elasticsearch/ElasticSearchTest.scala Adds pagination integration tests.
media-api/test/lib/elasticsearch/ElasticSearchTestBase.scala Adds graphic-image test data.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread media-api/app/lib/elasticsearch/ElasticSearch.scala
Comment thread media-api/app/lib/elasticsearch/ElasticSearch.scala
Comment thread media-api/app/controllers/MediaApi.scala
Comment thread media-api/app/lib/elasticsearch/sorts.scala
Comment thread media-api/app/lib/elasticsearch/sorts.scala Outdated
Comment on lines +671 to +672
val projectionIncludes: Seq[String] =
imageSourceFields.filterNot(searchAfterDropFields) ++ config.fieldAliasConfigs.map(_.elasticsearchPath)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kupua drops full-fat fileMetadata on the floor for perf. We pass on bits from it via fieldAliases (including “silent” ones, like xmp.pur:adultContentWarning to satiate Potentially Graphic without resorting to painful script. But whether we should allow passing it through when requested… I don’t know, so leaving open if only for info.

🤖 narrative:

Correct, and it's a deliberate trade-off rather than an oversight — but you're right that returning empty data is the wrong way to express it. The lean _source projection (the Image schema minus embedding/originalMetadata/fileMetadata, plus the fieldAliasConfigs leaf paths) is what takes a page from ~1.7 MB to ~370 KB, and the alias passthrough is the intended escape hatch for the few fileMetadata leaves the client actually needs. Nothing requests include=fileMetadata on this endpoint today.

Two options: reject include=fileMetadata outright, or honour it by making the drop-set conditional on include. If we do the latter it must stay dropped by default and only be fetched when explicitly asked for, so the payload win is never silently lost. Leaving open for a view on which.

Comment thread media-api/app/controllers/MediaApi.scala
Comment thread media-api/app/lib/elasticsearch/ElasticSearchModel.scala
Comment on lines +131 to +132
offset = (body \ "offset").asOpt[Int].getOrElse(0),
length = (body \ "length").asOpt[Int].getOrElse(10),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty sure it’s of no consequence, but left unresolved in case this non-consequence better be backed by some code (that’s not yet added).

🤖 narrative:

Valid, and I'd like a steer rather than deciding unilaterally. Cursor pagination is the alternative to offset pagination, not a companion to it — the client never sends offset, and there's no .from() anywhere in searchAfter. So my instinct is to reject a non-zero offset with a 422 rather than implement it, which makes the contract honest without adding a mode that shouldn't be used. The alternatives are applying .from() only when no cursor is present, or documenting that it's ignored. Leaving open for a view.

Comment thread media-api/app/lib/elasticsearch/ElasticSearch.scala Outdated
First batch of fixes from the Copilot review on PR #4849. Five mechanical
corrections on the cursor path only — no behaviour change for any existing
caller. Kahuna's createSort/parseSortBy and imageSearch are untouched.

Validation errors were 500s, not 422s. searchAfter threw InvalidUriParams
synchronously, before any Future existed, so the controller's .recover never
saw it. The body moves to a private searchAfterQuery; the public searchAfter
converts the throw to a failed Future. That also lets the odd
`return Future.failed(...)` cursor-length guard become a plain throw, so all
four validation sites now behave identically. The cursor-length case was the
one that already worked, which is why the existing "cursor-mismatch → 422"
test passed and masked the rest.

jsonToSort trusted its input. An empty sort entry threw
NoSuchElementException; a missing or non-string `order` threw
JsResultException; extra fields were silently discarded. Now shape-checked,
with every malformed shape surfacing as InvalidUriParams.

orderOf treated anything other than "desc" as ascending, so a typo such as
`decs` produced valid-looking but wrongly-ordered results. Now strictly
asc/desc.

An empty sort clause was accepted. A cursor endpoint without a deterministic
sort returns an unusable continuation cursor, so it is now rejected. The
check lives in the ES layer rather than the controller because media-api has
no controller tests — all test files are lib-level — and the controller's
existing .recover already maps InvalidUriParams to 422.

The syndication review-queue runtime mapping was missing. search() attaches
syndicationReviewQueueFixMapping when querying the review queue with
syndication.review.useRuntimeFieldsFix enabled; searchAfter built the same
filter but never declared the field. Elasticsearch does not error on an
unmapped field in a term query — it matches nothing — so the must_not clause
silently evaporated and images with an active deny-syndication lease could
reappear in the review queue, differing from Kahuna's answer for the same
query with no error and no log line. Root cause is leases.leases being a
plain (non-nested) object array: ES flattens access and endDate into unpaired
lists, and the Painless runtime field walks leases one at a time to restore
the pairing.

Tests were written red-first, which turned the reviewer's claims into
evidence: the pre-fix run produced exactly the predicted
NoSuchElementException and JsResultException, the silently-accepted "decs",
and a stack trace of jsonToSort throwing straight out of searchAfter past the
controller's recover.

New SortsTest.scala covers jsonToSort and orderOf directly (9 cases, no
Docker). ElasticSearchTest gains four cases and a third ES instance with the
runtime-fields flag enabled. TZ=UTC sbt "media-api/test" → 213/213 green; no
existing test needed changing.

The syndication test is knowingly weak: it passes with and without the fix.
The runtime field only diverges from the flat clause on multi-lease
documents, and every fixture has a single lease. Making it non-vacuous would
need a multi-lease fixture in the shared ElasticSearchTestBase.images, which
would shift hardcoded counts across several existing syndication tests —
disproportionate for a six-line parity fix whose equivalence to search() is
plain by inspection.

Still outstanding from the same review: the PIT _shard_doc tiebreaker, the
nested-aware null-zone exists query, and three API-contract questions for the
team.
Second batch from the Copilot review on PR #4849. One real bug fixed, one
claim investigated and refuted.

Null-zone exists was not nested-aware. When the primary sort field lives
inside a nested type, the null-zone filter built `must_not exists(field)` as
a root-level query. A root-level exists cannot match a parent document for a
field inside a nested mapping, so the must_not excluded nothing and images
that do have the field leaked into the null zone — returned a second time
across a full scroll. This is reachable from a real client sort:
usagesDateAdded sorts on usages.dateAdded with nested path "usages", and
usages is a NestedField in Mappings.usagesMapping. The primary FieldSort
already carries its nested path, so the fix reads it and wraps the exists in
a nestedQuery, keeping the flat form for non-nested fields. A failing-first
test showed eight usage-bearing fixtures leaking before the fix and none
after.

The PIT _shard_doc tiebreaker is dropped deliberately; no change. The
reviewer's premise holds under measurement — Elasticsearch 8.18 does append
an implicit _shard_doc to every hit's sort array under a point-in-time
snapshot, giving three values against a two-field client clause. The
predicted consequence does not: ES does not reject a shorter search_after,
it accepts it and compares the client's prefix, so a two-page PIT walk
already worked.

Preserving the tiebreaker was implemented, went green, and was reverted.
Cursors outlive the PIT: clients persist them and retry without a PIT once
one expires, and a _shard_doc value in a non-PIT search_after is rejected by
ES with a 400. It would also reject every client-synthesised cursor — seek
anchors and null-zone estimates are built client-side and cannot contain a
shard ordinal the client has no way to know. The truncation is therefore
correct, conditional on the caller's sort clause ending in a unique
tiebreaker; a deliberately non-unique sort was measured losing eight of
twenty-seven documents. That precondition is now stated in a comment, and
the server cannot detect field uniqueness on the caller's behalf.

Three integration tests added. The two PIT tests pin both halves of the
cursor contract: one asserts ES returns n+1 sort values while the cursor we
hand back is n, so a future ES change surfaces immediately; the other walks
the whole corpus a page at a time under a PIT and asserts nothing is lost or
repeated. TZ=UTC sbt "media-api/test" → 216/216 green.
paperboyo added a commit that referenced this pull request Aug 17, 2026
First batch of fixes from the Copilot review on PR #4849. Five mechanical
corrections on the cursor path only — no behaviour change for any existing
caller. Kahuna's createSort/parseSortBy and imageSearch are untouched.

Validation errors were 500s, not 422s. searchAfter threw InvalidUriParams
synchronously, before any Future existed, so the controller's .recover never
saw it. The body moves to a private searchAfterQuery; the public searchAfter
converts the throw to a failed Future. That also lets the odd
`return Future.failed(...)` cursor-length guard become a plain throw, so all
four validation sites now behave identically. The cursor-length case was the
one that already worked, which is why the existing "cursor-mismatch → 422"
test passed and masked the rest.

jsonToSort trusted its input. An empty sort entry threw
NoSuchElementException; a missing or non-string `order` threw
JsResultException; extra fields were silently discarded. Now shape-checked,
with every malformed shape surfacing as InvalidUriParams.

orderOf treated anything other than "desc" as ascending, so a typo such as
`decs` produced valid-looking but wrongly-ordered results. Now strictly
asc/desc.

An empty sort clause was accepted. A cursor endpoint without a deterministic
sort returns an unusable continuation cursor, so it is now rejected. The
check lives in the ES layer rather than the controller because media-api has
no controller tests — all test files are lib-level — and the controller's
existing .recover already maps InvalidUriParams to 422.

The syndication review-queue runtime mapping was missing. search() attaches
syndicationReviewQueueFixMapping when querying the review queue with
syndication.review.useRuntimeFieldsFix enabled; searchAfter built the same
filter but never declared the field. Elasticsearch does not error on an
unmapped field in a term query — it matches nothing — so the must_not clause
silently evaporated and images with an active deny-syndication lease could
reappear in the review queue, differing from Kahuna's answer for the same
query with no error and no log line. Root cause is leases.leases being a
plain (non-nested) object array: ES flattens access and endDate into unpaired
lists, and the Painless runtime field walks leases one at a time to restore
the pairing.

Tests were written red-first, which turned the reviewer's claims into
evidence: the pre-fix run produced exactly the predicted
NoSuchElementException and JsResultException, the silently-accepted "decs",
and a stack trace of jsonToSort throwing straight out of searchAfter past the
controller's recover.

New SortsTest.scala covers jsonToSort and orderOf directly (9 cases, no
Docker). ElasticSearchTest gains four cases and a third ES instance with the
runtime-fields flag enabled. TZ=UTC sbt "media-api/test" → 213/213 green; no
existing test needed changing.

The syndication test is knowingly weak: it passes with and without the fix.
The runtime field only diverges from the flat clause on multi-lease
documents, and every fixture has a single lease. Making it non-vacuous would
need a multi-lease fixture in the shared ElasticSearchTestBase.images, which
would shift hardcoded counts across several existing syndication tests —
disproportionate for a six-line parity fix whose equivalence to search() is
plain by inspection.

Still outstanding from the same review: the PIT _shard_doc tiebreaker, the
nested-aware null-zone exists query, and three API-contract questions for the
team.
paperboyo added a commit that referenced this pull request Aug 17, 2026
New phase-3-d3-searchafter-post-pr-review.md records the triage of the ten
Copilot review comments on PR #4849: per-comment verdicts with file cites,
the three-batch split, the open decisions, and the procedure for porting
fixes to the PR branch and replying to each comment.

Notable verdicts, all verified against source rather than taken on the
reviewer's word: comment 6 (usageStatus/syndicationStatus returning 500) is
byte-identical to the existing GET path and therefore not a regression;
comment 3 (nested null-zone exists) is real and kupua's own direct-ES adapter
already handles it correctly; comment 1 (PIT _shard_doc) is unverifiable
today because the PIT branch has no caller and every test passes pitId=None,
so it will be settled by experiment rather than argument.

Changelog gains the Batch A entry. Index updated.
paperboyo added a commit that referenced this pull request Aug 17, 2026
Second batch from the Copilot review on PR #4849. One real bug fixed, one
claim investigated and refuted.

Null-zone exists was not nested-aware. When the primary sort field lives
inside a nested type, the null-zone filter built `must_not exists(field)` as
a root-level query. A root-level exists cannot match a parent document for a
field inside a nested mapping, so the must_not excluded nothing and images
that do have the field leaked into the null zone — returned a second time
across a full scroll. This is reachable from a real client sort:
usagesDateAdded sorts on usages.dateAdded with nested path "usages", and
usages is a NestedField in Mappings.usagesMapping. The primary FieldSort
already carries its nested path, so the fix reads it and wraps the exists in
a nestedQuery, keeping the flat form for non-nested fields. A failing-first
test showed eight usage-bearing fixtures leaking before the fix and none
after.

The PIT _shard_doc tiebreaker is dropped deliberately; no change. The
reviewer's premise holds under measurement — Elasticsearch 8.18 does append
an implicit _shard_doc to every hit's sort array under a point-in-time
snapshot, giving three values against a two-field client clause. The
predicted consequence does not: ES does not reject a shorter search_after,
it accepts it and compares the client's prefix, so a two-page PIT walk
already worked.

Preserving the tiebreaker was implemented, went green, and was reverted.
Cursors outlive the PIT: clients persist them and retry without a PIT once
one expires, and a _shard_doc value in a non-PIT search_after is rejected by
ES with a 400. It would also reject every client-synthesised cursor — seek
anchors and null-zone estimates are built client-side and cannot contain a
shard ordinal the client has no way to know. The truncation is therefore
correct, conditional on the caller's sort clause ending in a unique
tiebreaker; a deliberately non-unique sort was measured losing eight of
twenty-seven documents. That precondition is now stated in a comment, and
the server cannot detect field uniqueness on the caller's behalf.

Three integration tests added. The two PIT tests pin both halves of the
cursor contract: one asserts ES returns n+1 sort values while the cursor we
hand back is n, so a future ES change surfaces immediately; the other walks
the whole corpus a page at a time under a PIT and asserts nothing is lost or
repeated. TZ=UTC sbt "media-api/test" → 216/216 green.
paperboyo added a commit that referenced this pull request Aug 17, 2026
…and add wire-contract tests

Three unrelated problems, all found while auditing kupua's own tests after
the media-api review work on PR #4849 finished. None were caused by that
work — its four commits contain no TypeScript at all — but the audit is what
surfaced them.

The unit suite was green for the wrong reason, and only on some machines.
vite.config.ts declares no `environment` under `test:`, so vitest runs in its
default node environment and nothing supplies DOM globals. Two test files
call sessionStorage directly, so whether they pass depends entirely on the
Node version: Node 25 exposes sessionStorage as a stable global, while Node
22 — the minimum this project's `engines` field allows — does not. Same
commit, 1045/1045 green on v25.8.1 and 13 failures on v22.12.0.

Fixed with `// @vitest-environment jsdom` docblocks on history-snapshot and
image-offset-cache. jsdom was already installed and declared in
devDependencies, just never wired up. selection-store needed nothing; it
already stubs storage via vi.stubGlobal and documents that it runs without a
DOM.

Both files also gain a deterministic environment guard asserting `typeof
window` is "object". This was verified load-bearing by temporarily removing a
docblock: the guard failed while the other twelve tests carried on passing on
Node 25. That is the point — a misconfiguration that previously failed only
for developers on older Node now fails identically everywhere, including for
whoever introduces it.

`npm run build` was broken and no test could have caught it. tsc failed on
CqlSearchInput: @guardian/cql's TextSuggestionOption.label is `string |
undefined`, meaning the key must be present but may hold undefined, whereas
this project's TypeaheadSuggestion.label is optional and may be absent
entirely. Adapted at the boundary in buildDynamicFieldFallback with a
LabelledSuggestion type, rather than making label required across the
codebase and rippling into every producer. Worth remembering that vitest
strips types via esbuild, so a green unit suite says nothing about whether
the project compiles.

Wire-contract tests added for the 422s the media-api work introduced.
POST /images/search-after now rejects a sort clause that is empty, names more
than one field per entry, or uses a direction other than asc/desc — and
nothing on this side pinned any of it. The direct-ES path tolerates all
three, so a future change to buildSortClause could have kept every existing
test green while breaking only --use-media-api. The sort-builders tests now
derive their token list from SORTABLE_FIELDS, so a newly sortable field is
covered automatically, and assert all three invariants for both
buildSortClause and reverseSortClause. The adapter test asserts the request
body always carries a non-empty sort, including when orderBy is absent.

Also archives the post-PR-review doc now that all ten review comments are
actioned, and clears the session worklog.

Unit 1131/1131, build passes, e2e 244/244.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Departmental tracking: work on a new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants