Faceted Search (Dimensions)
The marketplace exposes a listings.getDimensions endpoint that returns aggregation counts for every filter dimension. The frontend uses these counts to render dynamic filter panels — for example, “Near Mint (580)”, “Foil (120)”.
Overview
| Feature | Description |
|---|---|
| Endpoint | POST /orpc/listings/getDimensions (public, no auth) |
| Package | @repo/search — dimensions/ directory |
| Contract | @repo/api-dtos — marketplace/get-dimensions.ts |
| Pattern | Self-excluding (disjunctive) facet aggregations |
| Index | product_listing_current (parent-child join) |
What Problem Does This Solve?
When a user selects “Near Mint” in the condition filter, the condition facet should still show all available conditions with updated counts (not just “Near Mint”). This is called self-excluding or disjunctive faceting — each facet’s aggregation applies all active filters except its own, so users can always see what else is available.
Without self-excluding logic, selecting a value would collapse that facet to show only the selected value, making it impossible to multi-select or change your mind.
Input / Output
Input
The endpoint accepts the same filter structure used by the main search endpoint — product and listing filter objects:
{
product?: {
gameFilters?: { game: string, ...filters }, // strongly-typed per-game
expansionSlug?: string,
type?: EnumFilter,
category?: string,
nameSlug?: string,
name?: StringFilter, // game-agnostic product name text search
expansion?: StringFilter, // game-agnostic expansion text search
market?: { priceUs?, priceEu?, ... },
},
listing?: {
priceRange?: { min?: number, max?: number },
condition?: EnumFilter,
language?: EnumFilter,
shipsTo?: string[],
currency?: string,
graded?: boolean,
gradingService?: EnumFilter,
quantity?: RangeFilter,
sellerId?: StringFilter,
sellerUsername?: StringFilter
}
}Output
The response is structured into three groups, plus stats:
{
product: {
gameSlug: { "mtg": 1250, "pokemon": 380 },
expansionSlug: { "neo-discovery": 50, "base-set": 120 },
productType: { "single": 1500, "sealed": 80 },
productCategory: { "card": 1400, "token": 100 }
},
listing: {
currency: { "USD": 980, "EUR": 650 },
condition: { "near_mint": 580, "mint": 420 },
graded: { "graded": 25, "ungraded": 180 },
language: { "en": 1200, "fr": 300 },
// ...
},
attributes?: { // only when exactly 1 game is filtered
color: { "white": 120, "blue": 200 },
rarity: { "rare": 450, "mythic": 80 },
// ...
},
totalMatchingListings: 1630,
listingPriceStats?: {
min: 50, // cents
max: 99900 // cents
}
}attributes is only returned when the request filters to exactly one game (via gameFilters.game). With zero or multiple games, the game-specific attributes are omitted since they wouldn’t be meaningful across different games.
Architecture
All faceting code lives in @repo/search’s dimensions/ directory. The core abstraction is an abstract FacetFamily class with three concrete subclasses — one per group of facets. Each family knows what facets it owns, how to build self-excluding filters, and how to key its aggregations. The base class provides the shared self-excluding loop, and subclasses override behavior where needed (e.g., ListingFacetFamily adds parent-child wrapping).
Everything else — filter builders, clause builders, the response mapper — is plain exported functions. Facet definitions are static readonly arrays (no registry or caching). Game attribute definitions are resolved fresh on each request from the game configuration.
The DimensionBuilder service (the only @injectable class) composes all three families into a single aggregation body. The handler calls DimensionBuilder.build(), sends the query to OpenSearch, and passes the raw response through mapDimensionResponse() to produce the structured output.
The Three Facet Families
Product Facets
Always returned regardless of filters. These are universal fields that exist on every product.
| Key | OpenSearch Field | Bucket Size |
|---|---|---|
gameSlug | product.gameSlug | 50 |
expansionSlug | product.expansion.slug | 100 |
productType | product.productType | 20 |
productCategory | product.productCategory | 50 |
Self-excluding logic: when building the aggregation for gameSlug, the product filter excludes the gameSlug clause but keeps all other product filters and all attribute filters.
Listing Facets
Always returned, but require special structural wrapping because listings are child documents in the parent-child index.
| Key | OpenSearch Field | Bucket Size | Notes |
|---|---|---|---|
condition | listing.condition | 20 | |
language | listing.language | 20 | |
shipsTo | listing.shipsTo | 50 | |
currency | listing.currency | 20 | |
graded | listing.graded | — | Uses filters aggregation (exists/not-exists) |
gradingService | listing.graded.gradingService | 20 |
The ListingFacetFamily overrides buildAggregations() to wrap everything in:
facets_listing (filter: product filters + attribute filters)
└── _listing_children (children: { type: "listing" })
├── condition (filter: all listing filters except condition)
│ └── values (terms: listing.condition)
├── language (filter: all listing filters except language)
│ └── values (terms: listing.language)
├── ...other facets...
└── filtered_listings (filter: all listing filters except priceRange)
└── listing_price_stats (stats: listing.price)Filterable but NOT faceted: priceRange, quantity, sellerId, and sellerUsername are applied as filters but don’t have corresponding facet aggregations. They are always included in self-excluding logic (never excluded) since there is no facet to self-exclude for.
Price Stats Self-Exclusion
The listingPriceStats aggregation intentionally excludes the priceRange filter from its own filter. This ensures the returned min/max reflects the full available price range given all other active filters — not just the user’s current slider selection. Without this, the min/max would just echo back what the user already selected, making a price slider useless.
The graded Facet
The graded dimension is boolean-like: a listing is either graded or not. Instead of a terms aggregation, it uses a filters aggregation with two named filters:
{
"graded": { "exists": { "field": "listing.graded" } },
"ungraded": { "bool": { "must_not": { "exists": { "field": "listing.graded" } } } }
}This produces counts like { "graded": 25, "ungraded": 180 } in the response.
Game-Specific Attribute Facets
Only returned when exactly one game is filtered (via gameFilters). Definitions are resolved dynamically from the game configuration (@repo/game-configuration).
The resolver (resolveGameFacets()) iterates each attribute in the game config and:
- Skips base card fields (
name,finish,printNumber) — these aren’t useful as facets - Maps the attribute type to an OpenSearch suffix (
enum→_keyword,number→_number,boolean→_bool) - Skips non-facetable types (
_text,_date,null) - Resolves the field path — promoted fields go to
product.{key}, others toproduct.attrs.{key}{suffix}
Promoted fields are game-specific attributes that were moved to the product top-level for query efficiency:
| Attribute | Field Path |
|---|---|
rarity | product.rarity |
finishes | product.finishes |
variant | product.variant |
All other attributes use the product.attrs.{key}{suffix} pattern:
| Attribute | Type | Field Path |
|---|---|---|
color | enum | product.attrs.color_keyword |
cmc | number | product.attrs.cmc_number |
supertype | enum | product.attrs.supertype_keyword |
Adding a new game or new attributes to an existing game automatically generates facets — no code changes needed. The system is fully data-driven from the game configuration.
How Self-Excluding Aggregations Work
This is the core technique that makes faceted search useful. Here is what happens for a single facet, step by step.
Say the user has selected condition = Near Mint and language = English. When building the aggregation for the condition facet:
- Collect all active listing filter clauses
- Remove the
conditionclause (self-exclude) - Keep the
languageclause - Wrap the
termsaggregation forlisting.conditionin afilterusing only the kept clauses
The result: the condition facet shows counts filtered by language (and all product filters) but not by condition itself. So the user sees “Near Mint (580), Lightly Played (220), Moderately Played (90)” instead of just “Near Mint (580)”.
The base FacetFamily.buildAggregations() method implements this loop for every facet definition in the family. The getFilterClauses(context, excludeKey) abstract method is where each subclass decides what “exclude” means for its family.
Request Flow
The query sent to OpenSearch has size: 0 (we don’t need document hits, only aggregation results) and track_total_hits: false for performance.
Response Mapping
The mapDimensionResponse() function transforms the raw OpenSearch aggregation response into the structured output. It handles three aggregation shapes:
Terms aggregations — returned as { buckets: [{ key: "near_mint", doc_count: 580 }, ...] }. The mapper extracts key → doc_count pairs and filters out the __missing__ placeholder and zero-count entries.
Exists (filters) aggregations — returned as { buckets: { graded: { doc_count: 25 }, ungraded: { doc_count: 180 } } }. The mapper extracts named bucket keys with their counts.
Stats aggregation — the filtered_listings sub-aggregation provides doc_count for totalMatchingListings and listing_price_stats.{min,max} for listingPriceStats.
The mapper uses aggregation key prefixes to route results into the correct output group:
facets_product_*→productfacets_attrs_*→attributesfacets_listing._listing_children.*→listing
Usage
TypeScript API
import { ListingSearchService } from "@repo/search"
const searchService = container.resolve(ListingSearchService)
// Get dimensions for MTG cards with condition filter
const result = await searchService.getDimensions({
product: { gameFilters: { game: "mtg" } },
listing: {
condition: { op: "or", values: ["near_mint"] },
currency: "USD",
},
})
// result.product.gameSlug = { "mtg": 1250, "pokemon": 380, ... }
// result.listing.condition = { "near_mint": 580, "mint": 420, ... }
// result.attributes.color = { "white": 120, "blue": 200, ... }
// result.totalMatchingListings = 580
// result.listingPriceStats = { min: 50, max: 99900 }cURL
# Single game — returns product + listing + attributes
curl -X POST http://localhost:3000/orpc/listings/getDimensions \
-H "Content-Type: application/json" \
-d '{"json": {"product": {"gameFilters": {"game": "mtg"}}}}'
# With listing filters
curl -X POST http://localhost:3000/orpc/listings/getDimensions \
-H "Content-Type: application/json" \
-d '{"json": {"product": {"gameFilters": {"game": "mtg"}}, "listing": {"currency": "USD"}}}'
# No filters — returns all dimensions
curl -X POST http://localhost:3000/orpc/listings/getDimensions \
-H "Content-Type: application/json" \
-d '{"json": {}}'Code Locations
| Component | Location |
|---|---|
| API contract | packages/core/api-dtos/src/lib/marketplace/get-dimensions.ts |
| Handler | packages/backend/api/src/lib/orpc/handlers/listings/get-dimensions.handler.ts |
| Dimensions module | packages/backend/features/search/src/dimensions/ |
| FacetFamily base class | packages/backend/features/search/src/dimensions/facet-family.ts |
| Facet definitions | packages/backend/features/search/src/dimensions/facet-definitions.ts |
| DimensionBuilder | packages/backend/features/search/src/dimensions/dimension-builder.service.ts |
| Response mapper | packages/backend/features/search/src/dimensions/dimension-response-mapper.ts |
| Game attribute resolver | packages/backend/features/search/src/dimensions/game-attribute-resolver.ts |
| Filter builders | packages/backend/features/search/src/dimensions/filter-builders.ts |
| Attribute field mapping | packages/backend/features/search/src/dimensions/attribute-field-mapping.ts |