Item Filter & Sort — Implementation Notes
Branch: feature/filter-sort-ssrm-bff
Status: Deployed to Amplify dev
Date: 2026-04-23
See architecture.md for the authoritative reference (types, request flow, cache pattern, planned
freshAftercross-Lambda freshness).
How it works (plain English)
Section titled “How it works (plain English)”When a user opens the Items page, AG Grid’s Server-Side Row Model (SSRM) automatically requests the first page of items from the BFF. The BFF checks its cache — if this is the first request (or the cache expired), it loops the backend’s paginated API internally (100 items at a time) until it has every item for the tenant, then caches the result for 5 minutes. With the full dataset in cache, the BFF applies any active filters and sort, slices out the requested page, and returns it.
The user sees a loading overlay while the first page loads, then the grid appears with infinite scrolling. As they scroll down, AG Grid automatically requests the next page from the BFF — each request reads from the warm cache, applies filter/sort, and returns the next slice. All of this is fast (~5ms server-side on a cache hit).
Above the grid, a CloudScape PropertyFilter toolbar lets users add structured filters like “Type = Office” or type free text that searches across all columns. When a filter changes, the grid refreshes — AG Grid re-fetches from startRow 0 with the new filter tokens, and the BFF filters the cached dataset accordingly.
Sorting works by clicking column headers. AG Grid sends the sort model (which columns, which direction) with every SSRM request. The BFF’s sort engine applies it against the cached data.
Filter tokens are saved to localStorage via Redux, so they survive page reloads. When a user creates, edits, or deletes an item, the page busts the BFF cache and refreshes the grid — fresh data appears with no full page reload.
What changed (technical summary)
Section titled “What changed (technical summary)”We added multi-column filtering to the Items page using CloudScape PropertyFilter, and switched the data loading strategy from server-side cursor pagination (50 items/page) to AG Grid SSRM with infinite scrolling over a BFF-cached full tenant dataset. Filtering and sorting happen BFF-side against the cache. No backend API changes.
Before vs After
Section titled “Before vs After”Before (main branch)
Section titled “Before (main branch)”User opens /items |Page requests ONE page of 50 items |POST /api/arda/items/query -> { filter: true, paginate: { index: 0, size: 50 } } |Backend returns 50 items + cursor tokens (thisPage/nextPage/previousPage) |AG Grid renders 50 items as real DOM rows (no virtualization needed at 50 rows) |User clicks "Next Page" -> another POST request for the next 50 |Sort only reorders the current 50 items on screenNo column filtering available -- only a single search box (server-side regex)Limitations:
- Sort only worked on the visible page (50 items), not the full dataset
- No per-column filtering — just a single search box
- Every page navigation was a network round-trip
- Search box sent regex queries to the backend on every keystroke (debounced)
After (this branch)
Section titled “After (this branch)”User opens /items |AG Grid SSRM fires: POST /api/arda/items/query-ssrm { startRow: 0, endRow: 100, sortModel: [], filterTokens: [] } |BFF checks unstable_cache for this tenant | (cache miss) (cache hit) Loop backend internally: Return cached array (~5ms) 100 items/page x N pages until done stripToGridColumns on each result Cache the full array (5-min TTL) |BFF applies filterEngine (0 tokens = pass-through)BFF applies sortEngine (empty = natural order)BFF slices [0, 100] |Returns: { rows: [...], lastRow: 698, totalCount: 698, filterOptions: {...} } |AG Grid renders first page with infinite scrollPropertyFilter shows "698 items" with enum dropdown values populated |User scrolls -> SSRM requests next page -> BFF reads cache, slices [100, 200]User adds filter [Type = Office] -> SSRM re-fetches -> BFF filters -> 12 itemsUser clicks column header -> SSRM re-fetches -> BFF sorts -> same items, new order |Each interaction: BFF reads from cache (~5ms), filters, sorts, slices, returnsWhat’s better:
- Sort works across ALL items, not just one page
- Per-column filtering via CloudScape PropertyFilter (text, enum, free-text search)
- Infinite scroll — no pagination UI, no cursor tokens
- Filter/sort changes are fast — BFF reads from warm cache
- Browser only holds one page of items at a time (not the full dataset)
- Filter state persists across page reloads (Redux + localStorage)
How the data flows (complete picture)
Section titled “How the data flows (complete picture)”+----------------------------------------------------------------------+| page.tsx (component) || || persistedFilterQuery = useSelector(state.itemsFilterSort.filterQuery)|| ssrmFilteredCount, ssrmTotalCount, filteringOptions (state) || || +---------------------------------------------------------------+ || | <ItemsPropertyFilter> | || | Renders CloudScape <PropertyFilter> component | || | Shows: "12 of 698 items" (from ssrmFilteredCount/TotalCount) | || | Enum dropdowns populated from filteringOptions | || | onChange -> dispatch(setFilterQuery(query)) -> Redux | || | -> itemsGridRef.current.refreshGrid() | || +---------------------------------------------------------------+ || | || v || +---------------------------------------------------------------+ || | <ItemTableAGGrid | || | filterTokens={persistedFilterQuery.tokens} | || | filterOperation={persistedFilterQuery.operation} | || | onFilteredCountChange={setSsrmFilteredCount} | || | onSSRMMetadata={handleSSRMMetadata} | || | /> | || | | || | Inside ItemTableAGGrid: | || | filterTokensRef = useRef(filterTokens) (updated every render) || | | || | serverSideDatasource = useMemo(() => ({ | || | getRows: async (params) => { | || | const result = await queryItemsSSRM({ | || | startRow, endRow, | || | sortModel: params.request.sortModel, | || | filterTokens: filterTokensRef.current, | || | filterOperation: filterOperationRef.current, | || | }); | || | params.success({ rowData: result.rows, rowCount: result.lastRow });| | onFilteredCountChangeRef.current?.(result.lastRow); | || | if (first block) onSSRMMetadataRef.current?.(meta); | || | } | || | }), []); // stable — refs read latest values | || | | || | <ArdaGrid serverSideDatasource={serverSideDatasource} /> | || +---------------------------------------------------------------+ || |+----------------------------------------------------------------------+ | v+----------------------------------------------------------------------+| POST /api/arda/items/query-ssrm || || 1. processJWTForArda(request) -> extract tenantId, author, userId || 2. getCachedMappedItems(userContext, requestId) || -> unstable_cache with key ['items-all-mapped', tenantId] || -> if miss: getCachedItems -> fetchAllItemsUncached (loop) || then mapArdaItemToItem on each result || 3. applyFilters(allItems, filterTokens, filterOperation) || -> filterEngine.ts: match each token against item field accessors|| 4. applySorting(filtered, sortModel) || -> sortEngine.ts: multi-column sort with type-aware comparators || 5. sorted.slice(startRow, endRow) || 6. If startRow === 0: compute filterOptions (distinct values) || 7. Return { rows, lastRow, totalCount, filterOptions? } || |+----------------------------------------------------------------------+How the BFF endpoints work
Section titled “How the BFF endpoints work”Shared cache layer (_lib/cachedItems.ts)
Section titled “Shared cache layer (_lib/cachedItems.ts)”Both BFF endpoints share a single cache layer with two tiers:
getCachedMappedItems() | unstable_cache (mapped tier) key: ['items-all-mapped', tenantId] | getCachedItems() | unstable_cache (raw tier) key: ['items-all', tenantId] | fetchAllItemsUncached() | Loop backend: 100 items/page stripToGridColumns() on each Stop when: results < 100 (last page reached)Why two tiers? mapArdaItemToItem is expensive at scale (converts backend schema to frontend Item type). The mapped tier runs the mapper once per cache fill, not once per SSRM scroll request. Both tiers share the items-all tag — a single revalidateTag busts both.
Constants:
BACKEND_PAGE_SIZE = 100— items per internal loop iterationCACHE_TTL_SECONDS = 300— 5 minutesCACHE_TAG_PREFIX = 'items-all:'— tenant-scoped tag isCACHE_TAG_PREFIX + tenantIdHARD_MAX_ITEMS— BFF-memory cap per tenant. Loop halts andhardMaxHit: trueis returned when reached. Above this scale we migrate to query-DSL — not raise the cap.
Two ceilings apply: the 2MB
unstable_cacheper-entry limit (Next.jsIncrementalCache.set) constrains serialized cache size, andHARD_MAX_ITEMScaps the per-tenant working set in BFF memory (well under Lambda’s 1024MB default).stripToGridColumnsdropssecondarySupply,previous,createdBy,createdAtfrom each result.
paginate.index is an OFFSET, not a page number:
- Page 1:
{ index: 0, size: 100 } - Page 2:
{ index: 100, size: 100 } - Page 3:
{ index: 200, size: 100 }
SSRM endpoint (query-ssrm/route.ts)
Section titled “SSRM endpoint (query-ssrm/route.ts)”The primary data endpoint. Called by AG Grid’s SSRM datasource on every scroll, filter change, or sort change.
Client (AG Grid) BFF (query-ssrm) Cache | | | | POST /api/arda/items/query-ssrm | | { startRow: 0, endRow: 100, | | | sortModel: [{colId:'name', sort:'asc'}], | | filterTokens: [{propertyKey:'classificationType', | | operator:'=', value:'Office'}], | | filterOperation: 'and' } | | | ----------------------------->| | | | 1. processJWTForArda | | | 2. getCachedMappedItems() ------>| | | <- 698 Item objects | | | 3. applyFilters(698, tokens) | | | -> 12 items match | | | 4. applySorting(12, sortModel) | | | -> sorted by name asc | | | 5. slice(0, 100) -> 12 items | | | 6. startRow=0 -> compute | | | filterOptions from ALL 698 | | | | | <- { rows: [12 items], | | | lastRow: 12, | | | totalCount: 698, | | | filterOptions: { | | | classificationType: | | | ['Hardware','Office','Medical'], | | supplier: ['Acme','BestCo'], ... } } |Key design decisions in query-ssrm:
- filterOptions computed from unfiltered data. The enum dropdown always shows ALL possible values, not just values in the current filtered set. This matches user expectations — you can always see what other filter options exist.
- filterOptions only on first block. When
startRow === 0, the response includes distinct values for all string and boolean fields inITEM_FIELD_ACCESSORS_TYPED. Subsequent scroll requests skip this work. - totalCount is unfiltered. The PropertyFilter shows “12 of 698 items” —
lastRowis the filtered count,totalCountis the unfiltered count.
Fat-fetch endpoint (query-all/route.ts) — deprecated, removal pending
Section titled “Fat-fetch endpoint (query-all/route.ts) — deprecated, removal pending”Returns the full cached array. Used by fetchAllItems() in ardaClient.ts for compatibility with pre-SSRM code paths. Not used by the SSRM flow and not exposed to the browser in the production architecture. It must be removed before this feature ships to production:
- Drop
src/app/api/arda/items/query-all/route.ts. - Drop
fetchAllItems()fromsrc/lib/ardaClient.ts. - Drop the
query-allMSW handler fromsrc/mocks/handlers/items.ts.
Rationale: with infinite scroll, the client never needs the full set, and a fat-fetch endpoint would be CloudFront-bound (1MB) at any tenant scale that matters. HARD_MAX_ITEMS is a BFF-memory cap; it is not a CloudFront-response cap, because the full set never crosses CloudFront in this architecture.
How the filter engine works (_lib/filterEngine.ts)
Section titled “How the filter engine works (_lib/filterEngine.ts)”The filter engine applies CloudScape PropertyFilter tokens against Item objects. It uses the same ITEM_FIELD_ACCESSORS map as the PropertyFilter definitions — single source of truth for which fields are filterable and how to access them.
Token types
Section titled “Token types”- Property token — has a
propertyKey(e.g.,{ propertyKey: 'classificationType', operator: '=', value: 'Office' }). Matches against one specific field. - Free-text token — no
propertyKey(e.g.,{ operator: ':', value: 'bolt' }). Searches across ALL fields simultaneously (contains match).
Operators
Section titled “Operators”| Operator | Meaning | Example |
|---|---|---|
: | contains (case-insensitive) | “Acme” matches “Acme Corp” |
!: | not contains | ”Acme” does NOT match “Acme Corp” |
= | equals (case-insensitive) | “Office” matches “office” but not “Office Supplies” |
!= | not equals | ”Office” does NOT match “office” |
^ | starts with | ”Ac” matches “Acme” |
AND/OR logic
Section titled “AND/OR logic”operation: 'and'— ALL tokens must match for an item to passoperation: 'or'— ANY token matching is enough
How the sort engine works (_lib/sortEngine.ts)
Section titled “How the sort engine works (_lib/sortEngine.ts)”The sort engine applies AG Grid’s sort model to the item array. It supports multi-column sorting — the first element in the sort model is the primary sort, subsequent elements break ties.
Accessor map: Maps AG Grid colId values (from column definitions) to accessor functions that extract a comparable value from an Item. Supports:
- Direct string fields:
name,internalSKU,generalLedgerCode, etc. - Nested paths:
classification.type,primarySupply.supplier,locator.facility, etc. - Numeric fields:
primarySupply.unitCost.value,primarySupply.averageLeadTime, quantities - Boolean:
taxable(mapped to 0/1) - Timestamp:
createdCoordinates.effectiveAsOf
All string comparisons are case-insensitive (toLowerCase()). The sort returns a new array (never mutates the input).
How CloudScape PropertyFilter integrates
Section titled “How CloudScape PropertyFilter integrates”Architecture
Section titled “Architecture”Every file in this section is SPA-side (client). The shared
filteringProperties.tsis used in both layers — the SPA imports it for the PropertyFilter UI, and the BFF imports the same accessor map forfilterEngine/sortEngine. That shared module is the single source of truth for which fields are filterable.
[SPA] page.tsx (orchestrator): - persistedFilterQuery from Redux (useSelector) - ssrmFilteredCount, ssrmTotalCount, filteringOptions (useState) - handleSSRMMetadata callback: populates filteringOptions from first SSRM response - onQueryChange: dispatch(setFilterQuery) + refreshGrid()
[SPA] ItemsPropertyFilter.tsx (thin UI wrapper): - Receives query, filteredCount, totalCount, filteringOptions, onQueryChange - Renders CloudScape <PropertyFilter> component - Shows count text: "12 of 698 items" or "698 items" - Has i18n strings for all operator labels, buttons, ARIA
[SPA + BFF] filteringProperties.ts (field definitions + shared accessors): - ITEMS_FILTERING_PROPERTIES: 26 PropertyFilter property definitions [SPA] - ITEM_FIELD_ACCESSORS_TYPED: shared typed accessor map [SPA + BFF] used by PropertyFilter (SPA), filterEngine (BFF), sortEngine (BFF), and the enum-options builder (BFF) - flattenItemForFilter(): flattens nested Item fields for useCollection compatibility [SPA]Styles applied (and why CloudScape global styles are NOT imported)
Section titled “Styles applied (and why CloudScape global styles are NOT imported)”What we apply:
- PropertyFilter component CSS — ships scoped with
@cloudscape-design/components, imported per-component (no global side-effects). Covers token chips, popover, operator dropdowns, suggestions list. - AG Grid quartz theme — imported once at the page level (
ag-theme-quartzclass onArdaGridwrapper). Owns row height, header height, cell padding, focus rings. - Project tokens via Tailwind — typography scale, color palette, spacing apply at the page level above the toolbar; the grid sits inside its own theme container so AG Grid’s CSS variables aren’t overridden.
Source-of-truth for tokens lives in the design system. Coordinate any visual change with @nail60 before merging — the toolbar count text, dropdown spacing, and the grid header alignment are the most visible cross-cutting bits.
What we deliberately do NOT import:
CloudScape Design System ships a global stylesheet (@cloudscape-design/global-styles) that resets box-sizing, line-height, and font across the entire page. These CSS resets break AG Grid:
box-sizing: border-boxreset — AG Grid calculates row heights based oncontent-boxsizing in some internal elements. Forcedborder-boxcauses rows to overlap and scroll height to miscalculate.line-heightreset — changes text height inside cells, breaking thetotalHeight = rowCount x rowHeightmath that SSRM depends on.
The PropertyFilter component works without global styles — it ships its own scoped CSS. The mock file at __mocks__/@cloudscape-design/global-styles/index.css is a no-op to prevent accidental imports in tests.
Filterable columns
Section titled “Filterable columns”26 filterable fields are defined in ITEMS_FILTERING_PROPERTIES. Categories (see architecture.md for the full table):
| Visual category | Count | Operators | CloudScape tokenType | value format |
|---|---|---|---|---|
| Text (autocomplete) | 7 | : !: = != ^ | — (text input + autocomplete) | string |
| Enum (dropdown) | 12 | = != | 'enum' | string[] |
| Boolean | 1 | = != | 'enum' | string[] |
| Numeric | 5 | = != > >= < <= | — | string |
| Date | 1 | = != > >= < <= | — | string |
Enum dropdowns auto-populate: The first SSRM block response includes filterOptions — distinct values for each enum/boolean property computed from the full unfiltered dataset. So if your data has facilities “Main” and “Warehouse,” the Facility dropdown shows exactly those two options. Text fields receive autocomplete suggestions from the same sidecar.
Filter persistence (itemsFilterSortSlice)
Section titled “Filter persistence (itemsFilterSortSlice)”// State shape:interface ItemsFilterSortState { filterQuery: { tokens: PropertyFilterToken[], operation: 'and' | 'or' }; sortModel: Array<{ colId: string; sort: 'asc' | 'desc' }>;}
// Initial state:{ filterQuery: { tokens: [], operation: 'and' }, sortModel: [] }
// Persisted via redux-persist:// Key: 'itemsFilterSort'// Whitelist: ['filterQuery', 'sortModel']// Storage: localStorageOn page load, useSelector reads state.itemsFilterSort.filterQuery and passes it as the query prop to PropertyFilter and as filterTokens to ItemTableAGGrid. The SSRM datasource sends these tokens with every request.
How caching works
Section titled “How caching works”BFF cache (server-side)
Section titled “BFF cache (server-side)”When the cache refreshes:
- After any item create/update/delete:
revalidateTag('items-all:' + tenantId)via server action (src/app/actions/revalidateItems.ts) — tenant-scoped, never global - After 5 minutes (TTL ceiling)
- On Lambda cold start (cache is per-instance, lives in memory)
Amplify Hosting note: Each Lambda instance has its own cache. Two concurrent users may hit different Lambda instances with independent caches. The 5-minute TTL is the safety net. A shared Redis/ElastiCache cache is the escape hatch if per-instance hit rate is too low. The planned freshAfter timestamp pattern (see architecture.md) closes the same-user cross-Lambda freshness gap without distributed cache infrastructure.
Cache invalidation after mutations
Section titled “Cache invalidation after mutations”When a user creates, edits, or deletes an item:
const refreshCurrentPage = useCallback(async () => { await revalidateItemsCache(tenantId); // Server action -> revalidateTag('items-all:' + tenantId) itemsGridRef.current?.refreshGrid(); // Purge SSRM blocks + re-fetch from BFF}, []);refreshGrid() on ArdaGrid purges all cached SSRM blocks and re-fetches from startRow 0. Since the BFF cache was just invalidated, the BFF re-loops the backend and returns fresh data. The user sees the AG Grid loading overlay briefly, then updated results.
Client-side (no explicit cache)
Section titled “Client-side (no explicit cache)”There is no client-side data cache. AG Grid SSRM manages its own block cache internally (rows it has already fetched for the current scroll position). This is purged on refreshGrid(). Filter tokens and sort model live in Redux.
How SSRM datasource refs work
Section titled “How SSRM datasource refs work”The SSRM datasource is created once via useMemo(() => ..., []) — it must be stable so AG Grid doesn’t reset its internal state. But filter tokens and sort model change over time. The solution is refs:
const filterTokensRef = useRef(filterTokens);const filterOperationRef = useRef(filterOperation);filterTokensRef.current = filterTokens; // updated every renderfilterOperationRef.current = filterOperation;
const serverSideDatasource = useMemo(() => ({ getRows: async (params) => { const result = await queryItemsSSRM({ startRow: params.request.startRow ?? 0, endRow: params.request.endRow ?? 100, sortModel: params.request.sortModel, // from AG Grid filterTokens: filterTokensRef.current ?? [], // from ref (latest) filterOperation: filterOperationRef.current, // from ref (latest) }); params.success({ rowData: result.rows, rowCount: result.lastRow }); },}), []); // empty deps = stable referenceWhy refs? If filterTokens were in the useMemo deps, changing a filter would recreate the datasource, which would reset AG Grid’s SSRM state (scroll position, fetched blocks, etc.). Using refs lets the datasource closure always read the latest values without being recreated.
When filter changes: page.tsx dispatches setFilterQuery to Redux, then calls itemsGridRef.current?.refreshGrid(). This tells AG Grid to purge its block cache and call getRows again from startRow 0 — the datasource closure reads the new tokens from filterTokensRef.current.
Files added/modified
Section titled “Files added/modified”New files
Section titled “New files”| File | Purpose |
|---|---|
src/app/api/arda/items/query-all/route.ts | BFF fat-fetch endpoint — pagination loop + cache (used for fetchAllItems() compatibility) |
src/app/api/arda/items/query-ssrm/route.ts | BFF SSRM endpoint — filter + sort + paginate from cached data |
src/app/api/arda/items/_lib/cachedItems.ts | Shared cache layer — getCachedItems, getCachedMappedItems, fetchPage, pagination loop |
src/app/api/arda/items/_lib/filterEngine.ts | CloudScape token matching — applyFilters() with 5 operators + free-text search |
src/app/api/arda/items/_lib/sortEngine.ts | Multi-column sort — applySorting() with type-aware comparators for all grid columns |
src/app/items/ItemsPropertyFilter.tsx | CloudScape PropertyFilter UI wrapper — i18n, count text, onChange callback |
src/app/items/filteringProperties.ts | 26 field definitions + ITEM_FIELD_ACCESSORS_TYPED + flattenItemForFilter() |
src/store/slices/itemsFilterSortSlice.ts | Redux slice — filterQuery + sortModel, persisted to localStorage |
src/lib/mappers/itemsGridMapper.ts | stripToGridColumns() — drops secondarySupply, previous, createdBy, createdAt to fit under the 2MB unstable_cache per-entry limit |
src/app/actions/revalidateItems.ts | Server action — revalidateTag('items-all:' + tenantId) (tenant-scoped) |
__mocks__/@cloudscape-design/* | Jest mocks — PropertyFilter stub, useCollection passthrough, CSS no-op |
Modified files
Section titled “Modified files”| File | What changed |
|---|---|
src/app/items/page.tsx | SSRM metadata state (ssrmFilteredCount, ssrmTotalCount, filteringOptions), PropertyFilter integration, refreshCurrentPage() uses refreshGrid(), removed old search UI and pagination handlers |
src/app/items/ItemTableAGGrid.tsx | SSRM datasource with queryItemsSSRM, filter token refs, onFilteredCountChange, onSSRMMetadata callback |
src/components/table/ArdaGrid.tsx | serverSideDatasource prop, animateRows: true, rowBuffer: 20, suppressScrollOnNewData: true, row-count footer, flex layout (flex:1 minHeight:0) |
src/lib/ardaClient.ts | Added fetchAllItems() and queryItemsSSRM() |
src/store/rootReducer.ts | Registered itemsFilterSort slice with redux-persist (whitelist: filterQuery, sortModel) |
src/mocks/handlers/items.ts | Added query-all and query-ssrm MSW handlers for mock mode |
jest.config.js | Added moduleNameMapper entries for CloudScape ESM packages |
package.json | Added @cloudscape-design/components, collection-hooks, global-styles |
Amplify Hosting restrictions and how they shaped the design
Section titled “Amplify Hosting restrictions and how they shaped the design”The CloudFront 1MB response limit
Section titled “The CloudFront 1MB response limit”The constraint: Amplify Hosting routes ALL SSR and API route responses through CloudFront, which enforces a hard ~1MB limit on the combined response body + headers. Confirmed by aws-amplify/amplify-hosting#3214.
How SSRM mitigates this: Individual SSRM responses contain one page (~100 items, ~8KB compressed). The 1MB limit only affects the query-all endpoint used for BFF cache population — and that runs server-side (BFF to backend), not through CloudFront.
stripToGridColumns is doubly load-bearing: The cache stores the full tenant array, and Next.js enforces a 2MB unstable_cache per-entry limit (IncrementalCache.set). With ~2000 items, the raw ArdaResult payload exceeded this — primarily due to the previous cursor field (~700 bytes/item, ~1.4MB across 2000 items). Stripping drops secondarySupply, previous, createdBy, and createdAt, cutting each item from ~1-2KB to ~0.5-0.8KB. CloudFront’s 1MB response limit applies separately to anything we’d expose to a browser — SSRM responses are one page (~100 items, ~8KB compressed), comfortably under both limits. Budget:
CloudFront 1MB response budget (SSRM page = 100 items ~8KB compressed — N/A here): 698 items (current) x 0.6KB stripped / 5x gzip = ~84KB -- plenty of room2,000 items x 0.6KB stripped / 5x gzip = ~240KB -- comfortable5,000 items x 0.6KB stripped / 5x gzip = ~600KB -- fits8,000 items x 0.6KB stripped / 5x gzip = ~960KB -- tight against CloudFront
unstable_cache 2MB per-entry budget (the binding constraint for our cache fill): ~2,000 items at 0.6KB stripped each = ~1.2MB raw — fits with headroomPer-instance unstable_cache
Section titled “Per-instance unstable_cache”Each Lambda instance has its own cache. revalidateTag only purges the instance that ran the mutation. Other instances serve stale data until the 5-minute TTL expires or they receive their own mutation. The planned freshAfter timestamp pattern (see architecture.md) closes the same-user freshness gap. Shared Redis is the broader escape hatch if per-instance hit rate is too low.
Lambda cold starts
Section titled “Lambda cold starts”Cold start + cold cache: ~5-10s (Lambda init + backend loop). Warm Lambda + warm cache: ~50-200ms. The AG Grid loading overlay absorbs this.
unstable_cache deprecation in Next.js 16
Section titled “unstable_cache deprecation in Next.js 16”Deprecated in favor of use cache directive. Still works. Migration is mechanical — a follow-up task.
Release blockers
Section titled “Release blockers”These must land before the feature ships to production:
- Sort state restoration on mount. The Redux slice persists
sortModel, but it isn’t wired to AG Grid’s SSRMinitialSortModelon mount — sort is lost on reload while filters are restored. Reviewer flagged this as a release blocker, not a follow-up. WireinitialState.sort.sortModelfrom the persisted slice inItemTableAGGridmount. - Remove
query-all/fetchAllItems(). See Fat-fetch endpoint section — legacy fat-fetch must be deleted before shipping so the only client-visible data path is SSRM. - Tenant-scoped invalidation tag. Confirm
revalidateTag('items-all:' + tenantId)is what the deployed code calls (not the global'items-all'); a global tag would invalidate every tenant on every mutation.
Known issues and follow-ups
Section titled “Known issues and follow-ups”-
unstable_cachedeprecated in Next.js 16. Works today. Migration touse cache+cacheTag+cacheLifeis a follow-up. -
Per-instance cache (cross-Lambda freshness gap).
revalidateTagonly affects the Lambda instance that ran it. Other instances serve stale data up to the 5-minute TTL. The plannedfreshAftertimestamp pattern (see architecture.md) closes this gap for the mutating user without distributed cache. Shared Redis is the broader escape hatch if per-instance hit rate is too low. -
2MB
unstable_cacheper-entry limit. Bound by Next.js (IncrementalCache.set). Mitigated bystripToGridColumnsdroppingsecondarySupply,previous,createdBy,createdAt. Combined withHARD_MAX_ITEMS, tenants beyond this scale need query-DSL integration, not stripping further. -
CloudScape global styles can’t be imported. They break AG Grid’s row height calculation. The PropertyFilter works without them. If other CloudScape components need global styles, scope with CSS
@layer. -
hardMaxHitUX surface. When the BFF setshardMaxHit: true, the UI should show a toast warning the tenant they’ve exceededHARD_MAX_ITEMSand may not see all items. Currently logged BFF-side only.
How to test
Section titled “How to test”Required automated tests (must pass before merge)
Section titled “Required automated tests (must pass before merge)”Unit (Jest):
| Test file | Asserts |
|---|---|
filterEngine.test.ts | Each operator (:, !:, =, !=, ^, >, >=, <, <=) against string / numeric / date / boolean / enum-array fields; AND vs OR filterOperation; 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 returns hardMaxHit: true; tenant-scoped tag 'items-all:' + tenantId is applied to both tiers |
itemsGridMapper.test.ts | stripToGridColumns drops exactly secondarySupply, previous, createdBy, createdAt; keeps every grid-rendered field |
itemsFilterSortSlice.test.ts | Reducers; redux-persist whitelist (filterQuery, sortModel); rehydrate restores tokens AND sort model |
ItemTableAGGrid.test.tsx | SSRM datasource is stable across renders (useMemo deps []); refs deliver latest filter tokens to getRows; refreshGrid() purges blocks; initialState.sort.sortModel is hydrated from Redux on mount (covers the release blocker) |
E2E (Playwright, mock mode):
| Spec | Asserts |
|---|---|
items-filter.spec.ts (new) | Add token → grid narrows; remove token → restored; AND vs OR combination; free-text search hits all 26 fields; 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 (release-blocker coverage) |
items-mutation-refresh.spec.ts (new) | Edit → tenant-scoped 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 |
Local (mock mode)
Section titled “Local (mock mode)”npm run dev:mock# Navigate to /items# PropertyFilter visible, mock items loaded via SSRMLocal (real backend)
Section titled “Local (real backend)”npm run dev# Login with real credentials# Navigate to /items -- all published items load via SSRM# Use PropertyFilter to filter by Type, Supplier, Name, etc.What to verify
Section titled “What to verify”- Grid loads with infinite scroll (no pagination buttons)
- PropertyFilter shows correct count (“X of Y items” or “Y items”)
- Adding a filter token narrows the grid (SSRM re-fetches from BFF)
- Removing a filter token restores the full list
- Combining multiple tokens with AND narrows further
- Free-text search matches across all text columns
- Sorting a column header reorders the ENTIRE dataset (not just visible rows)
- Scroll is smooth — AG Grid SSRM requests next pages seamlessly
- Inline cell editing still works (double-click a notes cell)
- Filter tokens persist across page reload (refresh the browser)
- Creating/editing/deleting an item refreshes the grid with fresh data
Q&A — technical questions and answers
Section titled “Q&A — technical questions and answers”Data loading
Section titled “Data loading”Q: What happens when the user first opens /items?
AG Grid’s SSRM datasource fires getRows({ startRow: 0, endRow: 100 }) automatically. The BFF checks its cache — on a cold start, it loops the backend (7 requests for ~700 items), caches, filters, sorts, and returns the first 100 items. The user sees the AG Grid loading overlay, then the grid appears.
Q: What about subsequent page requests?
As the user scrolls, AG Grid fires additional getRows calls with increasing startRow. Each one hits the BFF, which reads from cache (~5ms), applies the current filter/sort, and returns the next slice. No full re-fetch.
Q: What happens when the user switches workspaces?
The useEffect in page.tsx detects tenantId change and calls refreshGrid(). This purges AG Grid’s block cache and triggers a fresh SSRM request. The BFF cache is keyed by tenantId, so each tenant has its own cache entry.
Filtering
Section titled “Filtering”Q: Where does filtering happen?
BFF-side. The filterEngine.ts in _lib/ applies CloudScape PropertyFilter tokens against the cached Item array. The browser sends tokens with every SSRM request; the BFF filters and returns the matching subset.
Q: What about free-text search?
A token without a propertyKey is treated as free-text. The filter engine checks it against ALL fields in ITEM_FIELD_ACCESSORS with a contains match. If any field contains the text, the item passes.
Q: Are filter options hardcoded?
No. Computed dynamically from the cached data on the first SSRM block response. The BFF scans all items and collects distinct values per filterable property. Only values that exist in the data appear in dropdowns.
Sorting
Section titled “Sorting”Q: Does sorting happen in AG Grid or the BFF?
The BFF. When the user clicks a column header, AG Grid updates its sort model and fires a new SSRM request with the updated sortModel. The BFF’s sortEngine.ts sorts the cached data accordingly.
Q: Does sorting work on the filtered set?
Yes. The pipeline is: read all items from cache -> filter -> sort -> slice. Sorting operates on the filtered result.
Caching
Section titled “Caching”Q: Is the BFF cache shared across users?
Shared across users of the same tenant on the same Lambda instance. Cache key is ['items-all', tenantId]. Different Lambda instances have independent caches.
Q: What happens after a mutation?
refreshCurrentPage() calls revalidateItemsCache(tenantId) (server action -> revalidateTag('items-all:' + tenantId)) then refreshGrid(). Only the mutating tenant’s cache entries are busted; SSRM re-fetches, BFF re-loops the backend, fresh data appears.
Performance
Section titled “Performance”Q: What’s the latency for a filter change?
BFF reads from cache (~5ms) + filter/sort (~1-5ms for 700 items) + network round-trip (~50-200ms). Total: ~60-210ms. Feels near-instant.
Q: What about scrolling performance?
Each scroll block request is the same: cache read + filter/sort + slice. ~60-210ms total. AG Grid pre-fetches blocks ahead of the viewport, so the user rarely sees loading states while scrolling at normal speed.
Copyright: © Arda Systems 2025-2026, All rights reserved