Spike #855 — Item List Filter & Sort
Architecture and implementation documentation for multi-column filter + sort on the /items page.
Ticket: Arda-cards/management#855
Branch: feature/filter-sort-ssrm-bff
Author: David Quintanilla
Last updated: 2026-04-27
Per-column filtering and multi-column sorting on the Items page using CloudScape PropertyFilter as the filter toolbar and AG Grid Server-Side Row Model (SSRM) for infinite scrolling. Filtering and sorting happen BFF-side over a full tenant dataset cached for 5 minutes. The backend supports filter/sort via the query-DSL pattern — we deferred integration to ship faster. Query-DSL integration is planned as a follow-up.
Architecture
Section titled “Architecture”BFF route definition
Section titled “BFF route definition”// POST /api/arda/items/query-ssrm// Requestinterface SSRMRequestBody { startRow: number; endRow: number; sortModel?: Array<{ colId: string; sort: 'asc' | 'desc' }>; filterTokens?: Array<{ propertyKey?: string; operator: string; value: string | string[] }>; filterOperation?: 'and' | 'or';}
// Responseinterface SSRMResponseData { rows: Item[]; lastRow: number; // filtered count (drives infinite scroll) totalCount?: number; // unfiltered count — cheap (cache.length on first block) filterOptions?: Record<string, string[]>; // only on first block (startRow=0) hardMaxHit?: boolean; // tenant exceeded the BFF-memory cap}See architecture.md for the full request flow, type definitions, and cache pattern.
Which doc to read
Section titled “Which doc to read”| If you want to… | Read | Time |
|---|---|---|
| Scan the feature quickly | summary.md | 5 min |
| Authoritative reference (types, flow, caching) | architecture.md | 15 min |
| Architecture diagrams | visuals.md | 10 min |
| Deep technical guide | implementation-notes.md | 30 min |
| Original proposal (historical) | proposal.md | 30 min |
Key decisions
Section titled “Key decisions”-
CloudScape PropertyFilter as the filter UI. Unified toolbar with AND/OR logic, free-text search across all columns, structured per-column filters. Replaces the old search box.
-
AG Grid SSRM with infinite scrolling. Pages of ~100 rows delivered on scroll. Browser never holds the full dataset. Future-proof — same row model needed when query-DSL is integrated.
-
BFF-side filtering and sorting. Filter engine and sort engine apply CloudScape tokens and AG Grid sort model against the cached dataset. Reusable when transitioning to query-DSL.
-
5-minute cache TTL. Short TTL protects against stale data from external API clients. Cache keyed by
['items-all', tenantId]— tenant-isolated via JWT. Invalidated on our mutations viarevalidateTag('items-all:' + tenantId)— tenant-scoped tag (busting'items-all'globally would grind the system to a halt across all tenants). Named constant:CACHE_TTL_SECONDS = 300. -
2MB
unstable_cacheper-entry limit drives column stripping. Next.js enforces a 2MB cap on each cache entry. With ~2000 items, the rawArdaResultpayload exceeded this — primarily due to thepreviouscursor field.stripToGridColumns()dropssecondarySupply,previous,createdBy, andcreatedAtto fit comfortably under the limit. -
HARD_MAX_ITEMScap per tenant. BFF Lambda memory cap, not CloudFront-derived. Beyond this scale, the migration path is query-DSL integration — not raising the cap. -
All data served via SSRM. No fat-fetch endpoint exposed to the client. Server-side page cap: 200 items per request. The legacy
query-allroute andfetchAllItems()client are deprecated and will be removed. -
Backend query-DSL integration deferred. The backend supports filter/sort via the query-DSL pattern. We deferred integration to ship faster. The SSRM + BFF architecture transitions cleanly — replace internal cache+filter+sort with a query-DSL call.
-
Filter/sort state persisted in Redux. CloudScape query + AG Grid sort model stored in
itemsFilterSortSlice, persisted to localStorage via redux-persist. -
totalCounton first block. Returned alongsidefilterOptionson thestartRow=0response. Cheap to compute — the cache is already in memory, so it’s justitems.length— no extra backend round-trip.
New files
Section titled “New files”| File | Layer | Purpose |
|---|---|---|
src/app/api/arda/items/query-ssrm/route.ts | BFF | SSRM endpoint — filter + sort + paginate from cache |
src/app/api/arda/items/_lib/cachedItems.ts | BFF | Cache logic — pagination loop, getCachedMappedItems, TTL |
src/app/api/arda/items/_lib/filterEngine.ts | BFF | CloudScape token matching (string/enum/numeric/date/boolean) |
src/app/api/arda/items/_lib/sortEngine.ts | BFF | Multi-column sort with type-aware comparators |
src/app/items/ItemsPropertyFilter.tsx | SPA | CloudScape PropertyFilter wrapper |
src/app/items/filteringProperties.ts | Shared | 26 field definitions + ITEM_FIELD_ACCESSORS_TYPED |
src/store/slices/itemsFilterSortSlice.ts | SPA | Redux slice — filter query + sort model |
src/lib/mappers/itemsGridMapper.ts | BFF | stripToGridColumns() — drops secondarySupply, previous, createdBy, createdAt |
src/app/actions/revalidateItems.ts | BFF | Server action — revalidateTag('items-all:' + tenantId) (tenant-scoped) |
Modified files
Section titled “Modified files”| File | Layer | What changed |
|---|---|---|
src/app/items/page.tsx | SPA | SSRM metadata, PropertyFilter, refreshCurrentPage |
src/app/items/ItemTableAGGrid.tsx | SPA | SSRM datasource, filter token refs |
src/components/table/ArdaGrid.tsx | SPA | SSRM mode, refreshGrid(), row-count footer |
src/lib/ardaClient.ts | SPA | Added queryItemsSSRM() |
src/store/rootReducer.ts | SPA | Registered itemsFilterSort slice |
Test plan
Section titled “Test plan”Tests required for this feature to ship to production:
Unit (Jest)
Section titled “Unit (Jest)”| Target | What it covers |
|---|---|
filterEngine.test.ts | Each operator (:, !:, =, !=, ^, >, >=, <, <=) against string / numeric / date / boolean / enum-array fields; AND vs OR; free-text token matches all fields |
sortEngine.test.ts | Asc/desc per type; multi-column tiebreakers; nested-path accessors; null/undefined ordering; no input mutation |
cachedItems.test.ts | Pagination loop terminates on partial page; HARD_MAX_ITEMS halts the loop and sets hardMaxHit; tenant-scoped tag 'items-all:' + tenantId is applied |
itemsGridMapper.test.ts | stripToGridColumns drops exactly secondarySupply, previous, createdBy, createdAt and keeps every grid-rendered field |
itemsFilterSortSlice.test.ts | Reducers; redux-persist whitelist (filterQuery, sortModel); rehydrate restores tokens |
ItemTableAGGrid.test.tsx | SSRM datasource is stable (useMemo deps []); refs deliver latest filter tokens to getRows; refreshGrid() purges blocks |
E2E (Playwright, mock mode)
Section titled “E2E (Playwright, mock mode)”| File | What it covers |
|---|---|
items-filter.spec.ts (new) | Add token → grid narrows; remove token → restored; AND vs OR combination; free-text search hits all columns; filter persists across reload |
items-sort.spec.ts (new) | Click header → reorders full dataset (not just visible page); multi-column sort; sort persists across reload |
items-mutation-refresh.spec.ts (new) | Edit → revalidateTag busts cache → grid shows fresh values without page reload |
items-search.spec.ts (regression) | Old search box flow replaced by PropertyFilter free-text token |
Manual smoke (pre-deploy)
Section titled “Manual smoke (pre-deploy)”- Real backend: filter, sort, mutate, scroll past 1000 rows.
- HARD_MAX warning surfaces in BFF logs when seeded with a tenant that exceeds
HARD_MAX_ITEMS. - Sort restoration on mount (release blocker — see implementation-notes.md).
Source materials
Section titled “Source materials”- Query-DSL pattern — backend filter/sort capability (deferred)
- Ticket #855 — feature ticket
- Parent epic #742 — items page improvements
What’s out of scope
Section titled “What’s out of scope”- Query-DSL integration (follow-up — architecture supports clean transition)
- Shared Redis/ElastiCache cache (escape hatch if per-instance hit rate is poor)
- Saved filter views / shareable URLs
Release blockers (must land before production)
Section titled “Release blockers (must land before production)”- Sort state restoration on mount. The Redux slice persists
sortModel, but it isn’t yet wired to AG Grid’s SSRMinitialSortModelon mount — sort is lost on reload while filters are restored. Reviewer flagged this as a release blocker. fetchAllItems()/query-allremoval. Legacy fat-fetch route still exists. Must be removed before shipping so the only client-visible data path is SSRM.
Copyright: © Arda Systems 2025-2026, All rights reserved