Frontend — React + TypeScript (Vite)
- Reason: The dashboard must keep filters, sort, and
page_sizein the URL while the table loads many rows incrementally. React Router’suseSearchParamsgives shareable/bookmarkable state without custom history glue; pairing that with TanStack Query keepsuseInfiniteQuery(server pages) anduseMutation(annotationPATCH) as separate concerns. TypeScript types inweb/src/api/types.tsmirror the gap shape the UI edits (annotation,id,tenant_id) so the modal and optimistic cache updates stay aligned with the contract — assessors can cross-check against/openapi.json.
Backend — Python + FastAPI
- Reason: The brief expects discoverable HTTP docs and a clear tenant boundary. FastAPI generates OpenAPI + Swagger UI from route signatures so
/docsand/openapi.jsonstay accurate without hand-maintained schemas; sharedX-Tenant-Iddependencies apply to every gap route so new endpoints don’t accidentally skip tenant checks — important when annotation edits arePATCH /gaps/{id}scoped by tenant.
What we shipped
- Server-side pagination:
GET /gapsusespage+page_size(capped at 1000). The UI usesuseInfiniteQuery, flattenspages→items, and loads the next page when the user scrolls near the bottom — infinite scroll on top of offset pages, not one giant download. - Virtualisation:
@tanstack/react-virtualrenders only rows near the viewport (fixed row height) so the DOM stays small even when 500+ loaded rows are cached client-side; the header row stays outside the scrollport for a stable grid chrome. - Tests / CI: integration tests use a temp SQLite DB seeded smaller than production defaults so pytest stays fast; the proof checklist still targets 500+ visible capability via filters/
page_sizeagainst the real seed.
What breaks around ~50 000 (filtered) rows
- SQLite
OFFSET: deep pages (pagelarge) get progressively slower — scanning/skipping large offsets hurts latency. - Client memory: if the user keeps loading pages forever, holding every fetched row in React Query grows RAM and reconciliation cost even though virtualisation limits DOM nodes.
What we’d change first
- API: cursor / key-set pagination (stable sort key +
id) instead of large offsets; optional payload slimming (omit heavy fields from list rows). - DB: composite indexes aligned to filter + sort (
tenant_id, filter columns, sort columns,id). - UI: trim or cap retained pages above the viewport if sessions routinely walk huge sets; debounce text search to avoid refetch storms.
Rollback on error — snapshot before mutate; restore in onError (from web/src/hooks/useAnnotationMutation.ts):
onMutate: async (vars) => {
await queryClient.cancelQueries({ queryKey: gapsQueryKey })
const prev = queryClient.getQueryData<InfiniteData<GapListResponse>>(gapsQueryKey)
queryClient.setQueryData<InfiniteData<GapListResponse>>(gapsQueryKey, (old) => {
if (!old) return old
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((g) =>
g.id === vars.id ? { ...g, annotation: vars.annotation } : g,
),
})),
}
})
return { prev }
},
onError: (_error, _vars, context) => {
if (context?.prev !== undefined) {
queryClient.setQueryData(gapsQueryKey, context.prev)
}
},Two users editing the same gap’s annotation within ~1 second
- There is no optimistic locking / ETag on
PATCHtoday: whoever’sPATCHcommits last wins in the database. - After
onSettled,invalidateQueriesrefetches the list; the slower client’s UI will overwrite local text with server truth, so the “loser” may see their typing disappear without a merge dialog. - A production fix:
If-Match/ version column / updated_at check onPATCH→ 409 Conflict and explicit merge or reload UX.
| Layer | First change |
|---|---|
| Database | Partition or shard by tenant_id (and/or time) so cold tenants don’t bloat hot paths; covering indexes for (tenant_id, …filters/sort…, id) so list + count stay index-friendly at scale. |
| API | Cursor-based list endpoints (no deep OFFSET), strict page caps, and optional summary rollups / async materialised counts so /summary doesn’t scan billions per request. |
| UI | Incremental loading only (never assume full result sets), tighter cache eviction for infinite-query pages, and row-level optimistic patch targets so the client doesn’t pretend it holds “the whole world” in memory. |