Item Filter & Sort — Architecture Reference
Overview
Section titled “Overview”Multi-column filtering and sorting for the /items page using CloudScape PropertyFilter (filter UI) + AG Grid SSRM (infinite scroll) + BFF caching (Next.js unstable_cache).
All filtering and sorting happens server-side on the BFF (Lambda), not on the ARDA backend or in the browser. The BFF fetches all items once, caches them for 5 minutes, and applies filter/sort/pagination on each SSRM request.
Request Flow
Section titled “Request Flow”File Map
Section titled “File Map”BFF Layer (runs on Lambda)
Section titled “BFF Layer (runs on Lambda)”| File | Purpose |
|---|---|
src/app/api/arda/items/query-ssrm/route.ts | SSRM endpoint. Receives filter/sort/pagination params, reads from cache, applies filter+sort, returns paginated slice. |
src/app/api/arda/items/_lib/cachedItems.ts | Cache layer. Loops through ARDA backend pages (100/page), caches stripped results via unstable_cache (5-min TTL, tag items-all). Two tiers: raw (StrippedArdaResult[]) and mapped (Item[]). |
src/app/api/arda/items/_lib/filterEngine.ts | Applies CloudScape PropertyFilter tokens to items. Handles string, enum (array), numeric, date, and boolean fields. Supports AND/OR operation. |
src/app/api/arda/items/_lib/sortEngine.ts | Multi-column sort. Maps AG Grid colId to accessor functions. Returns new sorted array (no mutation). |
src/lib/mappers/itemsGridMapper.ts | Strips secondarySupply, previous, createdBy, createdAt from each ArdaResult to stay under the 2MB unstable_cache entry limit. |
src/app/actions/revalidateItems.ts | Server action: revalidateTag('items-all:' + tenantId) (tenant-scoped). Called after item create/update/delete. |
Frontend Layer (runs in browser)
Section titled “Frontend Layer (runs in browser)”| File | Purpose |
|---|---|
src/app/items/page.tsx | Orchestrates SSRM state. Holds filteringOptions, ssrmFilteredCount, ssrmTotalCount. Dispatches filter changes to Redux. Passes filterTokens to grid. |
src/app/items/ItemTableAGGrid.tsx | AG Grid with SSRM datasource. Uses refs pattern for filter/sort state stability. Calls queryItemsSSRM() on every scroll/filter/sort. |
src/app/items/ItemsPropertyFilter.tsx | Controlled CloudScape PropertyFilter. Renders filter bar, count text, dropdown suggestions. Reports changes via onQueryChange. |
src/app/items/filteringProperties.ts | Central field definitions. 26 fields with typed accessors (getString, getNumber), operator configs, and CloudScape property definitions. |
src/lib/ardaClient.ts | Client-side queryItemsSSRM() — POST to /api/arda/items/query-ssrm with auth headers. |
State Management
Section titled “State Management”| File | Purpose |
|---|---|
src/store/slices/itemsFilterSortSlice.ts | Redux slice: filterQuery (tokens + operation) and sortModel. Persisted to localStorage. |
src/store/rootReducer.ts | Registers itemsFilterSort reducer with whitelist: ['filterQuery', 'sortModel']. |
Type Definitions
Section titled “Type Definitions”FilterToken — what the BFF receives per filter condition
Section titled “FilterToken — what the BFF receives per filter condition”export interface FilterToken { propertyKey?: string; operator: FilterOperator | string; /** CloudScape sends a string for text fields, string[] for enum (tokenType: 'enum') fields. */ value: string | string[];}SortModelItem — one column in the multi-column sort
Section titled “SortModelItem — one column in the multi-column sort”export interface SortModelItem { colId: string; sort: 'asc' | 'desc';}SSRMRequestBody — the SSRM endpoint body
Section titled “SSRMRequestBody — the SSRM endpoint body”interface SSRMRequestBody { startRow: number; endRow: number; sortModel?: SortModelItem[]; filterTokens?: FilterToken[]; filterOperation?: 'and' | 'or';}SSRM response shape
Section titled “SSRM response shape”{ rows: Item[]; // paginated slice lastRow: number; // total filtered count (drives infinite scroll) totalCount?: number; // unfiltered total hardMaxHit?: boolean; filterOptions?: Record<string, string[]>; // sidecar — only on startRow=0}Redux state shape
Section titled “Redux state shape”export interface PropertyFilterToken { propertyKey?: string; operator: string; /** String for text fields, string[] for enum (multi-select) fields. */ value: string | string[];}
export interface PropertyFilterQuery { tokens: PropertyFilterToken[]; operation: 'and' | 'or';}
export type ItemsSortModel = Array<{ colId: string; sort: 'asc' | 'desc'; sortIndex?: number;}>;Stripped cache types (2MB-limit fix)
Section titled “Stripped cache types (2MB-limit fix)”export type StrippedArdaItemPayload = Omit<ArdaItemPayload, 'secondarySupply'>;
export type StrippedArdaResult = Omit< ArdaResult<StrippedArdaItemPayload>, 'previous' | 'createdBy' | 'createdAt'>;Field accessor type
Section titled “Field accessor type”type FieldType = 'string' | 'number' | 'date' | 'boolean';
interface FieldAccessor { type: FieldType; getString: (item: Item) => string; getNumber?: (item: Item) => number;}Field Types & Operators
Section titled “Field Types & Operators”26 filterable fields defined in ITEMS_FILTERING_PROPERTIES:
| 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 |
Note: in
ITEM_FIELD_ACCESSORS_TYPEDall enum fields sharetype: 'string'(the underlying data type is a string). The “enum vs text” distinction lives inITEMS_FILTERING_PROPERTIES.operators(whethertokenType: 'enum'is set), not in the accessortype.
CloudScape Token Value Types (per official spec)
Section titled “CloudScape Token Value Types (per official spec)”CloudScape’s PropertyFilterToken.value is typed as any because it varies by tokenType:
Text field: { propertyKey: "name", operator: ":", value: "Demo" } // stringEnum field: { propertyKey: "color", operator: "!=", value: ["RED","BLUE"] } // string[]Numeric field: { propertyKey: "unitCost", operator: ">", value: "50" } // stringFree-text: { operator: ":", value: "search term" } // string (no propertyKey)Filter Engine Semantics
Section titled “Filter Engine Semantics”| Operator + value shape | Semantics |
|---|---|
Scalar =, !=, :, !:, ^ (string field) | Direct string comparison (matchesStringOperator) |
Scalar =, !=, >, >=, <, <= (numeric/date field) | Numeric comparison (matchesNumericOperator) |
Array = | Item matches ANY value (OR within token) |
Array != | Item matches NONE of the values (AND within token) |
No propertyKey (free text) | Substring match against ALL fields’ getString() |
matchesScalar() dispatches to the right comparator based on the accessor’s type.
SSRM Endpoint
Section titled “SSRM Endpoint”Server-side bounds protection
Section titled “Server-side bounds protection”const startRow = Math.max(0, Math.floor(body.startRow ?? 0));const endRow = Math.max(startRow, Math.min(startRow + 200, Math.floor(body.endRow ?? 100)));Caps page size at 200 rows even if the client sends a larger window — protects the BFF from accidental or malicious oversized requests.
Pipeline
Section titled “Pipeline”processJWTForArda(request)— auth + user contextgetCachedMappedItems(userContext)— read from cache (or fetch + cache on miss)applyFilters(items, tokens, operation)— in-memory filteringapplySorting(filtered, sortModel)— in-memory multi-column sortsorted.slice(startRow, endRow)— pagination- (If
startRow === 0) buildfilterOptionssidecar for PropertyFilter dropdowns
Caching Architecture
Section titled “Caching Architecture”2MB Cache Limit (Next.js core constraint)
Section titled “2MB Cache Limit (Next.js core constraint)”Next.js enforces a 2MB per-entry limit on unstable_cache (enforced in IncrementalCache.set, not Amplify-specific). With ~2000 items, the raw ArdaResult payload exceeded this due to the previous field (base64 cursor, ~700 bytes/item).
Fix: stripToGridColumns() returns StrippedArdaResult (see Type Definitions) — explicitly picks only rId, asOf, payload (minus secondarySupply), metadata, author, retired. Drops previous (~1.4MB saved across 2000 items), createdBy, createdAt.
HARD_MAX_ITEMS — BFF memory cap per tenant
Section titled “HARD_MAX_ITEMS — BFF memory cap per tenant”The cache cap is driven by BFF Lambda memory, not by CloudFront response size (SSRM responses are one page ~100 items, never the full set). The cap is sized to fit comfortably under Lambda’s 1024MB default and the 2MB unstable_cache per-entry limit when serialized.
If a tenant exceeds HARD_MAX_ITEMS, the BFF logs a warning and sets hardMaxHit: true on the response. Beyond this scale, the migration path is query-DSL integration (server-side filter/sort against the backend), not raising the cap.
Cache Invalidation
Section titled “Cache Invalidation”Cache is invalidated per tenant to avoid grinding the system with cross-tenant invalidations. Both cache tiers share a tenant-scoped tag items-all:${tenantId} (not the global items-all), so revalidateTag('items-all:tenantA') only busts the Lambda’s cache entries for tenant A.
After any item create/update/delete:
- Server action
revalidateItemsCache(tenantId)callsrevalidateTag('items-all:' + tenantId)— purges only this tenant’s entries on this Lambda itemsGridRef.current.refreshGrid()— AG Grid purges SSRM cache and re-requests from BFF- BFF sees cache is stale → re-fetches from ARDA backend → re-caches
Cross-Lambda freshness is handled by the planned
freshAftertimestamp pattern (next section), not byrevalidateTag.
Refs Pattern (SSRM Datasource Stability)
Section titled “Refs Pattern (SSRM Datasource Stability)”The SSRM datasource is created once via useMemo([], []). Mutable state (filter tokens, callbacks) lives in refs that are re-pointed on every render:
filterTokensRef.current = filterTokens(latest value, no re-render)filterOperationRef.current = filterOperationonFilteredCountChangeRef.current = onFilteredCountChangeonSSRMMetadataRef.current = onSSRMMetadata
The getRows closure inside the datasource reads from these refs, so it always sees the latest values without recreating the datasource itself.
Why not recreate the datasource? AG Grid would:
- Reset scroll to top
- Clear its internal row cache (all previously loaded blocks)
- Flash/re-render the entire grid
Instead, refreshGrid() calls api.refreshServerSide({ purge: true }) to tell AG Grid to re-request data using the existing datasource — which reads the latest filter state from refs.
Filter Options (Dropdown / Autocomplete Suggestions)
Section titled “Filter Options (Dropdown / Autocomplete Suggestions)”Populated on the first SSRM block (startRow === 0) as a sidecar in the response — no separate API call needed.
The BFF builds options for all string and boolean fields in ITEM_FIELD_ACCESSORS_TYPED:
- Enum fields (Type, Department, Color…) → CloudScape renders as dropdown checklists because
tokenType: 'enum'is set in their operators - Text fields (Name, SKU, Notes…) → CloudScape renders as autocomplete suggestions in the text input
- Numeric and date fields are excluded — free-text input only
Page-level handler transforms { supplier: ["ACME","Widgets"] } into CloudScape’s flat FilteringOption[] shape: [{ propertyKey: "supplier", value: "ACME" }, { propertyKey: "supplier", value: "Widgets" }].
State Persistence (Redux)
Section titled “State Persistence (Redux)”Filter query and sort model persist to localStorage via redux-persist (whitelist: ['filterQuery', 'sortModel']).
Example persisted state after the user adds filters and a sort:
{ filterQuery: { tokens: [ { propertyKey: "supplier", operator: "=", value: ["ACME"] }, { propertyKey: "name", operator: ":", value: "Demo" } ], operation: "and" }, sortModel: [ { colId: "name", sort: "asc", sortIndex: 0 } ]}On page reload:
- Redux rehydrates
filterQueryfrom localStorage page.tsxpassespersistedFilterQuery.tokensasfilterTokenstoItemTableAGGrid- First SSRM
getRowscall includes the restored filter tokens - User sees the same filters they had before reload
Cross-Lambda Freshness: freshAfter Timestamp
Section titled “Cross-Lambda Freshness: freshAfter Timestamp”The Problem
Section titled “The Problem”AWS Amplify runs Next.js on Lambda behind CloudFront. Each Lambda instance has its own in-process unstable_cache — there is no shared cache across instances. revalidateTag() only purges the cache on the Lambda that handled the call; it cannot broadcast to other Lambdas.
This creates a freshness gap for the user who just mutated:
The Pattern
Section titled “The Pattern”Client tracks when its last successful mutation happened (per tenant) and sends it as freshAfter in every SSRM request. The BFF compares against the cache’s filledAt timestamp — if the cache was filled before freshAfter, it bypasses cache and refetches from the backend.
Updated request/response types (planned)
Section titled “Updated request/response types (planned)”// SSRM request body — adds freshAfterinterface SSRMRequestBody { startRow: number; endRow: number; sortModel?: SortModelItem[]; filterTokens?: FilterToken[]; filterOperation?: 'and' | 'or'; freshAfter?: number; // epoch ms — bypass cache if cache.filledAt < this}
// Cache value shape — adds filledAtinterface CachedItemsEntry { items: Item[]; count: number; hardMaxHit: boolean; filledAt: number; // epoch ms — set when this Lambda fills the cache}Properties
Section titled “Properties”| Aspect | Effect |
|---|---|
| Same-user cross-Lambda freshness | Immediate after mutation (no 5-min stale window) |
| Other tenants | Unaffected — freshAfter is per-tenant in localStorage |
| Other users in same tenant | Unaffected — only the mutating user’s localStorage holds the timestamp |
| Cache hit rate | Slightly lower for mutating users (one extra refetch per mutation) |
| Backend load | Slightly higher per mutation, not per request |
| Infrastructure | No shared cache needed (no Redis, no ElastiCache) — works with existing unstable_cache |
This is a client-driven cache bust that solves the cross-Lambda freshness gap without adding distributed cache infrastructure.
Constants
Section titled “Constants”| Constant | Value | Location | Purpose |
|---|---|---|---|
BACKEND_PAGE_SIZE | 100 | cachedItems.ts | Items per page when fetching from ARDA backend |
CACHE_TTL_SECONDS | 300 (5 min) | cachedItems.ts | unstable_cache revalidation interval |
CACHE_TAG_PREFIX | 'items-all:' | cachedItems.ts | Tenant-scoped tag is CACHE_TAG_PREFIX + tenantId for revalidateTag() |
HARD_MAX_ITEMS | (named constant) | cachedItems.ts | BFF-memory cap per tenant. Above this, migration path is query-DSL — not raising the cap |
| SSRM page cap | 200 | route.ts | Math.max(startRow, Math.min(startRow + 200, endRow)) |
| SSRM cacheBlockSize | 100 | ArdaGrid.tsx | AG Grid SSRM block size |
| SSRM maxBlocksInCache | 10 | ArdaGrid.tsx | AG Grid keeps 10 blocks in memory (1000 rows) |
Copyright: © Arda Systems 2025-2026, All rights reserved