Skip to content

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.


PlantUML diagram

src/app/api/arda/items/query-ssrm/route.ts
// POST /api/arda/items/query-ssrm
// Request
interface SSRMRequestBody {
startRow: number;
endRow: number;
sortModel?: Array<{ colId: string; sort: 'asc' | 'desc' }>;
filterTokens?: Array<{ propertyKey?: string; operator: string; value: string | string[] }>;
filterOperation?: 'and' | 'or';
}
// Response
interface 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.


If you want to…ReadTime
Scan the feature quicklysummary.md5 min
Authoritative reference (types, flow, caching)architecture.md15 min
Architecture diagramsvisuals.md10 min
Deep technical guideimplementation-notes.md30 min
Original proposal (historical)proposal.md30 min

  1. 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.

  2. 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.

  3. 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.

  4. 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 via revalidateTag('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.

  5. 2MB unstable_cache per-entry limit drives column stripping. Next.js enforces a 2MB cap on each cache entry. With ~2000 items, the raw ArdaResult payload exceeded this — primarily due to the previous cursor field. stripToGridColumns() drops secondarySupply, previous, createdBy, and createdAt to fit comfortably under the limit.

  6. HARD_MAX_ITEMS cap per tenant. BFF Lambda memory cap, not CloudFront-derived. Beyond this scale, the migration path is query-DSL integration — not raising the cap.

  7. All data served via SSRM. No fat-fetch endpoint exposed to the client. Server-side page cap: 200 items per request. The legacy query-all route and fetchAllItems() client are deprecated and will be removed.

  8. 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.

  9. Filter/sort state persisted in Redux. CloudScape query + AG Grid sort model stored in itemsFilterSortSlice, persisted to localStorage via redux-persist.

  10. totalCount on first block. Returned alongside filterOptions on the startRow=0 response. Cheap to compute — the cache is already in memory, so it’s just items.length — no extra backend round-trip.


FileLayerPurpose
src/app/api/arda/items/query-ssrm/route.tsBFFSSRM endpoint — filter + sort + paginate from cache
src/app/api/arda/items/_lib/cachedItems.tsBFFCache logic — pagination loop, getCachedMappedItems, TTL
src/app/api/arda/items/_lib/filterEngine.tsBFFCloudScape token matching (string/enum/numeric/date/boolean)
src/app/api/arda/items/_lib/sortEngine.tsBFFMulti-column sort with type-aware comparators
src/app/items/ItemsPropertyFilter.tsxSPACloudScape PropertyFilter wrapper
src/app/items/filteringProperties.tsShared26 field definitions + ITEM_FIELD_ACCESSORS_TYPED
src/store/slices/itemsFilterSortSlice.tsSPARedux slice — filter query + sort model
src/lib/mappers/itemsGridMapper.tsBFFstripToGridColumns() — drops secondarySupply, previous, createdBy, createdAt
src/app/actions/revalidateItems.tsBFFServer action — revalidateTag('items-all:' + tenantId) (tenant-scoped)
FileLayerWhat changed
src/app/items/page.tsxSPASSRM metadata, PropertyFilter, refreshCurrentPage
src/app/items/ItemTableAGGrid.tsxSPASSRM datasource, filter token refs
src/components/table/ArdaGrid.tsxSPASSRM mode, refreshGrid(), row-count footer
src/lib/ardaClient.tsSPAAdded queryItemsSSRM()
src/store/rootReducer.tsSPARegistered itemsFilterSort slice

Tests required for this feature to ship to production:

TargetWhat it covers
filterEngine.test.tsEach operator (:, !:, =, !=, ^, >, >=, <, <=) against string / numeric / date / boolean / enum-array fields; AND vs OR; free-text token matches all fields
sortEngine.test.tsAsc/desc per type; multi-column tiebreakers; nested-path accessors; null/undefined ordering; no input mutation
cachedItems.test.tsPagination loop terminates on partial page; HARD_MAX_ITEMS halts the loop and sets hardMaxHit; tenant-scoped tag 'items-all:' + tenantId is applied
itemsGridMapper.test.tsstripToGridColumns drops exactly secondarySupply, previous, createdBy, createdAt and keeps every grid-rendered field
itemsFilterSortSlice.test.tsReducers; redux-persist whitelist (filterQuery, sortModel); rehydrate restores tokens
ItemTableAGGrid.test.tsxSSRM datasource is stable (useMemo deps []); refs deliver latest filter tokens to getRows; refreshGrid() purges blocks
FileWhat 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
  • 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).


  • 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 SSRM initialSortModel on mount — sort is lost on reload while filters are restored. Reviewer flagged this as a release blocker.
  • fetchAllItems() / query-all removal. Legacy fat-fetch route still exists. Must be removed before shipping so the only client-visible data path is SSRM.