Skip to content

Item Filter & Sort — Architecture Reference

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.


PlantUML diagram


FilePurpose
src/app/api/arda/items/query-ssrm/route.tsSSRM endpoint. Receives filter/sort/pagination params, reads from cache, applies filter+sort, returns paginated slice.
src/app/api/arda/items/_lib/cachedItems.tsCache 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.tsApplies 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.tsMulti-column sort. Maps AG Grid colId to accessor functions. Returns new sorted array (no mutation).
src/lib/mappers/itemsGridMapper.tsStrips secondarySupply, previous, createdBy, createdAt from each ArdaResult to stay under the 2MB unstable_cache entry limit.
src/app/actions/revalidateItems.tsServer action: revalidateTag('items-all:' + tenantId) (tenant-scoped). Called after item create/update/delete.
FilePurpose
src/app/items/page.tsxOrchestrates SSRM state. Holds filteringOptions, ssrmFilteredCount, ssrmTotalCount. Dispatches filter changes to Redux. Passes filterTokens to grid.
src/app/items/ItemTableAGGrid.tsxAG Grid with SSRM datasource. Uses refs pattern for filter/sort state stability. Calls queryItemsSSRM() on every scroll/filter/sort.
src/app/items/ItemsPropertyFilter.tsxControlled CloudScape PropertyFilter. Renders filter bar, count text, dropdown suggestions. Reports changes via onQueryChange.
src/app/items/filteringProperties.tsCentral field definitions. 26 fields with typed accessors (getString, getNumber), operator configs, and CloudScape property definitions.
src/lib/ardaClient.tsClient-side queryItemsSSRM() — POST to /api/arda/items/query-ssrm with auth headers.
FilePurpose
src/store/slices/itemsFilterSortSlice.tsRedux slice: filterQuery (tokens + operation) and sortModel. Persisted to localStorage.
src/store/rootReducer.tsRegisters itemsFilterSort reducer with whitelist: ['filterQuery', 'sortModel'].

FilterToken — what the BFF receives per filter condition

Section titled “FilterToken — what the BFF receives per filter condition”
src/app/api/arda/items/_lib/filterEngine.ts
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”
src/app/api/arda/items/_lib/sortEngine.ts
export interface SortModelItem {
colId: string;
sort: 'asc' | 'desc';
}

SSRMRequestBody — the SSRM endpoint body

Section titled “SSRMRequestBody — the SSRM endpoint body”
src/app/api/arda/items/query-ssrm/route.ts
interface SSRMRequestBody {
startRow: number;
endRow: number;
sortModel?: SortModelItem[];
filterTokens?: FilterToken[];
filterOperation?: 'and' | 'or';
}
{
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
}
src/store/slices/itemsFilterSortSlice.ts
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;
}>;
src/lib/mappers/itemsGridMapper.ts
export type StrippedArdaItemPayload = Omit<ArdaItemPayload, 'secondarySupply'>;
export type StrippedArdaResult = Omit<
ArdaResult<StrippedArdaItemPayload>,
'previous' | 'createdBy' | 'createdAt'
>;
src/app/items/filteringProperties.ts
type FieldType = 'string' | 'number' | 'date' | 'boolean';
interface FieldAccessor {
type: FieldType;
getString: (item: Item) => string;
getNumber?: (item: Item) => number;
}

26 filterable fields defined in ITEMS_FILTERING_PROPERTIES:

Visual categoryCountOperatorsCloudScape tokenTypevalue format
Text (autocomplete)7: !: = != ^— (text input + autocomplete)string
Enum (dropdown)12= !='enum'string[]
Boolean1= !='enum'string[]
Numeric5= != > >= < <=string
Date1= != > >= < <=string

Note: in ITEM_FIELD_ACCESSORS_TYPED all enum fields share type: 'string' (the underlying data type is a string). The “enum vs text” distinction lives in ITEMS_FILTERING_PROPERTIES.operators (whether tokenType: 'enum' is set), not in the accessor type.

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" } // string
Enum field: { propertyKey: "color", operator: "!=", value: ["RED","BLUE"] } // string[]
Numeric field: { propertyKey: "unitCost", operator: ">", value: "50" } // string
Free-text: { operator: ":", value: "search term" } // string (no propertyKey)
Operator + value shapeSemantics
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.


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.

  1. processJWTForArda(request) — auth + user context
  2. getCachedMappedItems(userContext) — read from cache (or fetch + cache on miss)
  3. applyFilters(items, tokens, operation) — in-memory filtering
  4. applySorting(filtered, sortModel) — in-memory multi-column sort
  5. sorted.slice(startRow, endRow) — pagination
  6. (If startRow === 0) build filterOptions sidecar for PropertyFilter dropdowns

PlantUML diagram

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 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:

  1. Server action revalidateItemsCache(tenantId) calls revalidateTag('items-all:' + tenantId) — purges only this tenant’s entries on this Lambda
  2. itemsGridRef.current.refreshGrid() — AG Grid purges SSRM cache and re-requests from BFF
  3. BFF sees cache is stale → re-fetches from ARDA backend → re-caches

Cross-Lambda freshness is handled by the planned freshAfter timestamp pattern (next section), not by revalidateTag.


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 = filterOperation
  • onFilteredCountChangeRef.current = onFilteredCountChange
  • onSSRMMetadataRef.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" }].


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:

  1. Redux rehydrates filterQuery from localStorage
  2. page.tsx passes persistedFilterQuery.tokens as filterTokens to ItemTableAGGrid
  3. First SSRM getRows call includes the restored filter tokens
  4. User sees the same filters they had before reload

Cross-Lambda Freshness: freshAfter Timestamp

Section titled “Cross-Lambda Freshness: freshAfter Timestamp”

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:

PlantUML diagram

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.

PlantUML diagram

// SSRM request body — adds freshAfter
interface 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 filledAt
interface CachedItemsEntry {
items: Item[];
count: number;
hardMaxHit: boolean;
filledAt: number; // epoch ms — set when this Lambda fills the cache
}
AspectEffect
Same-user cross-Lambda freshnessImmediate after mutation (no 5-min stale window)
Other tenantsUnaffected — freshAfter is per-tenant in localStorage
Other users in same tenantUnaffected — only the mutating user’s localStorage holds the timestamp
Cache hit rateSlightly lower for mutating users (one extra refetch per mutation)
Backend loadSlightly higher per mutation, not per request
InfrastructureNo 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.


ConstantValueLocationPurpose
BACKEND_PAGE_SIZE100cachedItems.tsItems per page when fetching from ARDA backend
CACHE_TTL_SECONDS300 (5 min)cachedItems.tsunstable_cache revalidation interval
CACHE_TAG_PREFIX'items-all:'cachedItems.tsTenant-scoped tag is CACHE_TAG_PREFIX + tenantId for revalidateTag()
HARD_MAX_ITEMS(named constant)cachedItems.tsBFF-memory cap per tenant. Above this, migration path is query-DSL — not raising the cap
SSRM page cap200route.tsMath.max(startRow, Math.min(startRow + 200, endRow))
SSRM cacheBlockSize100ArdaGrid.tsxAG Grid SSRM block size
SSRM maxBlocksInCache10ArdaGrid.tsxAG Grid keeps 10 blocks in memory (1000 rows)