feat(documents): paginate /documents search so first paint isn't 1500 rows #315
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
/documents(the home page) callsGET /api/documents/searchwhich today returns every matching row in a single response — ~1500 documents with nopage/sizesupport. The backend additionally runs full enrichment (FTS match snippets, completion stats, contributor stacks) on every row before responding, and the frontend renders all 1500DocumentRowcomponents on first paint with no virtualization. Initial load is visibly slow.The fastest win is classical offset pagination — server returns one page at a time, enrichment happens only for that page, and the user navigates via URL params (
?page=). This matches the convention already in place forGET /api/notifications(the only other paged endpoint in the project).Recommended approach — classic page/size pagination
?page=0(0-indexed, matches Spring DataPageRequest).q,from,to,senderId,receiverId,tag[],tagOp,tagQ,sort,dir) are preserved unchanged;pageis an orthogonal param.pageto 0 on the frontend.Not pursued:
Backend changes
DocumentController.search(lines 243–263)Add two query params:
Switch the return type from
ResponseEntity<DocumentSearchResult>toResponseEntity<PagedDocumentSearchResult>(a thin wrapper withcontent,totalElements,totalPages,number,size— matching the SpringPage<T>shape emitted for notifications so the openapi-typescript consumer already understands it).DocumentService.searchDocuments(lines 358–406)Current flow returns a full
List<DocumentSearchItem>+ enrichment. New flow, minimal churn:DATE/TITLE/UPLOAD_DATE/RELEVANCE— push sort +PageRequestinto the repository call (findAll(Specification, Pageable)).SENDER/RECEIVER— keep the in-memory sort (it handles null parties) but page-slice the sorted list withsubList(page*size, min((page+1)*size, total)).buildResultrunssearchMatchData, completion stats, and contributors against the 50-item slice, not the full 1500.PagedDocumentSearchResult.Why this ordering wins: the hot path (DATE sort, default) now skips loading every row into memory AND skips enrichment on rows the user will never see on this page. Even the slow-path in-memory sorts (SENDER/RECEIVER) get cheap enrichment.
DocumentRepositoryThe existing
findAll(Specification<Document> spec, Pageable pageable)is already inherited fromJpaSpecificationExecutor— no repo change needed for the fast path.Tests
DocumentControllerTest: assert that?page=0&size=50returns 50 items andtotalElementsequals the full match count.DocumentControllerTest: assert that?page=2&size=50returns items 100–149.DocumentServiceTest: lock in that enrichment is only called for page items (verify mock call counts).DocumentServiceTest: SENDER-sort + page=1 returns the correct slice of a 120-doc fixture.Frontend changes
frontend/src/routes/documents/+page.server.tspagefrom the URL (Number(url.searchParams.get('page') ?? '0'), clamp to ≥ 0).page+ a constantsize = 50toapi.GET('/api/documents/search', ...).{ items, total, totalPages, page, size, ...existingFilters }.frontend/src/routes/documents/+page.sveltetotalPages+pageto a new<Pagination>control rendered underDocumentList.triggerSearch()function that builds URL params, resetpage=0whenever any filter changes.frontend/src/lib/components/Pagination.svelte(new, ~40 lines)page: number,totalPages: number,makeHref: (p: number) => string.« Zurück | Seite X von Y | Weiter »— three links. Prev/Next disabled at the bounds.aria-labelon each link,aria-current="page"on the current number.pagination_prev/pagination_next/pagination_page_ofParaglide keys in de/en/es.DocumentList.svelteitemsit receives — it doesn't know (or care) that the slice is paginated. The empty-state message stays sensible.totalElements.i18n
Add to
frontend/messages/{de,en,es}.json:Tests
frontend/src/lib/components/Pagination.svelte.spec.ts: renders prev disabled at page 0, next disabled at page === totalPages-1, href builder called with expected numbers, "Page 3 of 10" text present.OpenAPI regeneration
Run
npm run generate:apiinfrontend/after the backend response shape changes soDocumentSearchResult→PagedDocumentSearchResultpropagates to the typed client.Critical files
Backend
backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java— add params, new return typebackend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java— paginate + enrich slice onlybackend/src/main/java/org/raddatz/familienarchiv/dto/PagedDocumentSearchResult.java— new (or reuse SpringPage<T>)backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.javabackend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.javaFrontend
frontend/src/routes/documents/+page.server.ts— readpageparam, pass throughfrontend/src/routes/documents/+page.svelte— wire pagination componentfrontend/src/lib/components/Pagination.svelte— newfrontend/src/lib/components/Pagination.svelte.spec.ts— newfrontend/src/routes/DocumentList.svelte— drop per-group countsfrontend/messages/{de,en,es}.json— three new keysfrontend/src/lib/generated/api.ts— regeneratedVerification
curl 'http://localhost:8080/api/documents/search?size=50&page=0'returns 50 items andtotalElements≥ 1500;curl '…?page=29'returns the tail slice.cd frontend && npx vitest run src/lib/components/Pagination.svelte.spec.ts./documents, confirm first paint is ~50 rows not 1500. Measure with DevTools Performance (TTI < 1s on localhost).?page=1and the second slice renders./documents?q=foo&page=5in a new tab — state is driven from the URL.Acceptance criteria
/documentsfirst paint shows ≤ 50 rows regardless of how many match.?page=Npreserves every existing filter and is shareable via URL.DocumentServiceTest).DocumentControllerTest/DocumentServiceTest/page.svelte.spec/ a11y sweep.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
frontend/src/routes/DocumentList.svelte:112-117, the group header already only rendersgroup.label— there is no per-group count to drop. The top-level count at line 94 (m.docs_result_count({ count: total })) is the only one, and it should stay backed bytotalElements. Just remove that AC line; nothing to drop.NotificationController.java:49-58already usesPage<NotificationDTO>directly as the return type. The issue recommends a dedicatedPagedDocumentSearchResultDTO instead — agreed, that's the safer call. Raw SpringPage<T>leakspageable.sort.sorted/unsortedandpageable.unpagedfields into the TypeScript types, which openapi-typescript renders asRecord<string, never>noise and diverges from whatDocumentSearchResultconsumers already expect.Recommendations
search_returns_50_items_and_totalElements_when_page0_size50_given_full_fixturesearch_returns_empty_content_with_correct_totalElements_when_page_beyond_lastsearch_rejects_size_101(400) — this is what@Validon the param annotation buys you.Then service:
searchDocuments_enrichesOnlyCurrentPageSlice— verifysearchMatchDatamock called with 50 ids, not 1500. This is the perf invariant; lock it.searchDocuments_SENDER_sort_page1_returnsCorrectSlice_of120DocFixture.PagedDocumentSearchResultvs. just adding fields toDocumentSearchResult. I'd pick the latter — addpageNumber,pageSize,totalPagesnext to the existingitems/total. One record type, one codegen shape, no downstream renames.DocumentSearchResultalready carriestotal(line 11), which is the same astotalElements— rename it tototalElementsfor Spring-Page parity and you're done.searchDocuments, aftersortBySender/sortByFirstReceiver, you need one line:List<Document> slice = sorted.subList(Math.min(page*size, sorted.size()), Math.min((page+1)*size, sorted.size()));— extract into a privatepageSlice(List<T>, int, int)helper so the edge math lives once. Tested once in a utility test, reused twice.triggerSearchchange is one line. InsidetriggerSearch()(+page.svelte:38-51), simply do not carrypageinto the new params object. Any filter call already rebuilds from local state (q,from,to, etc.) andpageis NOT in that set — so you're implicitly at page 0 already. But do add apageparam when the Pagination component callsgoto.Open Decisions
(none — the shape is clear and the one genuine ambiguity was the DTO naming, which I've recommended above)
🏛️ Markus Keller — Senior Application Architect
Observations
NotificationController.java:50which already returnsPage<NotificationDTO>. Convention established; extending it doesn't require any new architectural concept.DocumentRepositoryalready inherits fromJpaSpecificationExecutorsofindAll(Specification, Pageable)is free — no repo changes. Confirmed by readingDocumentService.searchDocumentsatbackend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java:358-406.Recommendations
Pageableinto the service method, notint page, int size.searchDocuments(..., Pageable pageable)is the Spring idiom — it bundles page/size/sort and lets you pass aPageImplthrough the in-memory sort paths. Then the controller doesPageRequest.of(page, size)at the edge, same pattern asNotificationController.java:57.Page<DocumentSearchItem>internally, map to a domain DTO at the controller boundary. Same pattern asNotificationService.getNotifications(returnsPage<NotificationDTO>) + a thin controller that just relays it. Keeps the service's return type honest about the pagination contract.DocumentService.java:378-380is a memory trap past ~10k docs (loads all rows, sorts, slices). At 1500 rows, not a concern. At 15k it starts to bite. I'd add one comment line: "In-memory sort cost scales linearly with match count; acceptable whiledocumentsstays under ~10k. Past that, replace with SQL-level LEFT JOIN sort."Open Decisions
(none)
🔒 Nora Steiner — Application Security Engineer
Observations
@ValidatedonDocumentControlleris a blocker, not a nit. The issue spec proposes@Min(0) @Max(100) int size, but these Jakarta Bean Validation annotations only fire when the controller class (or method) is annotated@Validated.NotificationController.java:32has@Validated.DocumentControllerdoes not. Without it, a client sending?size=999999silently bypasses validation and the server happily processes all 1500 rows + enrichment — which is the exact DoS vector pagination is supposed to eliminate.Recommendations
@ValidatedtoDocumentControllerat the class level, matchingNotificationController.java:32. One-line change, enables every existing@Min/@Max/@Patternacross every endpoint. Add a controller-level test that?size=101returns 400 — without this test, someone could silently remove@Validatedlater and reopen the DoS window.pagetoo, not justsize. The issue has@Min(0)on page but no upper bound. A client sending?page=2147483647(INT_MAX) creates aPageRequestwith offset = 2147483647 × 50 which overflows andfindAllmay produce undefined behavior (Hibernate may translate to negative OFFSET; postgres will fail). Either@Max(100000)or checkpage <= totalPagesserver-side. Low probability, zero cost to bound.@Validtriggers aMethodArgumentNotValidException→ 400 response. If the app'sGlobalExceptionHandlerdoesn't already log these at WARN with the client IP, consider it — pattern-of-failure lookups on rejectedsizevalues reveal brute-force attempts.Open Decisions
(none — the validation gap is a must-fix, not a choice)
🧪 Sara Holt — QA Engineer
Observations
DocumentList.sveltegroup headers currently don't show per-group counts (per Felix's scope note), so there's no existing test to update there.Recommendations
Backend
DocumentControllerTestadditions beyond the issue's four:search_returns_400_when_size_exceeds_max—?size=101, expect 400. This is the test that guards@Validatedfrom being silently removed.search_returns_400_when_page_negative—?page=-1, expect 400.search_returns_empty_content_with_correct_totalElements_when_page_beyond_last—?page=999&size=50with a 30-item fixture. Expect 200,content: [],totalElements: 30. Spring'sPagedoes this correctly; lock it.search_applies_page_and_size_to_filtered_query— combine?q=foo&page=1&size=10and assert the FTS ranked-ID slice is respected.Backend
DocumentServiceTest— lock the perf invariant Sara cares about most: "enrichment runs only for page items":verify(repo, times(1)).findAll(any(Specification.class), any(Pageable.class))— confirms the fast-path usesPageable, notList<Document> findAll(Specification).buildResultis called with a 50-item list, not 1500.Frontend
Pagination.svelte.spec.ts— the issue lists bounds cases. I'd add:Tabreaches prev,Enternavigates — stable keyboard nav is a Leonie-shared concern but testable here.aria-current="page"rendered on current page number.toHaveClass('min-h-[44px]')or computed height ≥ 44px).Frontend page test — add one case: "filter change resets page to 0." Mount
?q=foo&page=5, simulateq='bar'change, assertgotowas called withoutpage. Without this test, a later refactor could silently break filter-reset behaviour.Regression safety on typegen. After
npm run generate:api, re-runnpm run check. IfDocumentSearchResult→PagedDocumentSearchResultrenaming cascades into places I can't easily see, type errors will surface.Open Decisions
(none)
⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
Recommendations
/api/documents/search, capture p95 latency before merge and after. This is the kind of change that looks good in a one-line Grafana chart — "latency dropped from 1.4s p95 to 180ms p95 on 2026-04-24" is a useful data point for future capacity conversations./api/documents/searchshould NOT setCache-Control: public— query results vary per user's permissions.private, max-age=0, must-revalidate(or no cache header at all) is the right posture. If the endpoint currently sets anything caching-related, confirm it's either absent orprivate.RateLimitInterceptorregistered (per the backend config package). If it isn't already applied to/api/documents/search, consider adding it — 50/min/user would be plenty for human browsing and would throttle any accidental paginator-loop bug on the client.Open Decisions
(none)
🎨 Leonie Voss — UX Designer & Accessibility Advocate
Observations
aria-current="page"are in the spec — exactly right for the senior audience floor. ✓triggerSearch()(+page.svelte:50) usesgoto(url, { keepFocus: true, noScroll: true }). For filter changes this is right — the user's typing or tag-picking, no one wants a scroll jump. But for pagination clicks, the same option keeps the user stranded mid-list after the slice changes, which is disorienting especially for seniors.«/»live inside the<a>text, screen readers read them; they should be wrapped in<span aria-hidden="true">or dropped entirely.Recommendations
makeHref(or wherever the paginationgotolives), usegoto(url, { keepFocus: true })— dropnoScroll: true. SvelteKit's default scroll behavior will put the user at the top of the new page, which is what they expect. KeepnoScroll: trueONLY in the existing filter-changetriggerSearchpath. Mental model: filter change = same results surface being narrowed, don't move me; page change = next surface, take me to the top.aria-hiddenon decorative chevrons.< 480px, lay the control out as: prev button on left, next button on right (both 44px, flex-1), "Seite X von Y" centered on a line above the buttons. At ≥ 480px, inline them. A singlesm:flex-row flex-colswap in Tailwind handles it.focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2. Links without visible focus leave keyboard users stranded.axe-playwrightin light and dark.Open Decisions
triggerSearchusesnoScroll: truefor filter changes and that's correct there — this is specifically about the pagination-click path. If you feel strongly that scroll should be preserved (e.g. for power users who want to compare page N and page N+1 via quick toggle), say so — it's the one genuine UX tradeoff in this change.