Skip to Content
BackendOpensearchFaceted Search (Dimensions)

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

FeatureDescription
EndpointPOST /orpc/listings/getDimensions (public, no auth)
Package@repo/search — dimensions/ directory
Contract@repo/api-dtos — marketplace/get-dimensions.ts
PatternSelf-excluding (disjunctive) facet aggregations
Indexproduct_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.

KeyOpenSearch FieldBucket Size
gameSlugproduct.gameSlug50
expansionSlugproduct.expansion.slug100
productTypeproduct.productType20
productCategoryproduct.productCategory50

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.

KeyOpenSearch FieldBucket SizeNotes
conditionlisting.condition20
languagelisting.language20
shipsTolisting.shipsTo50
currencylisting.currency20
gradedlisting.graded—Uses filters aggregation (exists/not-exists)
gradingServicelisting.graded.gradingService20

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:

  1. Skips base card fields (name, finish, printNumber) — these aren’t useful as facets
  2. Maps the attribute type to an OpenSearch suffix (enum → _keyword, number → _number, boolean → _bool)
  3. Skips non-facetable types (_text, _date, null)
  4. Resolves the field path — promoted fields go to product.{key}, others to product.attrs.{key}{suffix}

Promoted fields are game-specific attributes that were moved to the product top-level for query efficiency:

AttributeField Path
rarityproduct.rarity
finishesproduct.finishes
variantproduct.variant

All other attributes use the product.attrs.{key}{suffix} pattern:

AttributeTypeField Path
colorenumproduct.attrs.color_keyword
cmcnumberproduct.attrs.cmc_number
supertypeenumproduct.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:

  1. Collect all active listing filter clauses
  2. Remove the condition clause (self-exclude)
  3. Keep the language clause
  4. Wrap the terms aggregation for listing.condition in a filter using 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_* → product
  • facets_attrs_* → attributes
  • facets_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

ComponentLocation
API contractpackages/core/api-dtos/src/lib/marketplace/get-dimensions.ts
Handlerpackages/backend/api/src/lib/orpc/handlers/listings/get-dimensions.handler.ts
Dimensions modulepackages/backend/features/search/src/dimensions/
FacetFamily base classpackages/backend/features/search/src/dimensions/facet-family.ts
Facet definitionspackages/backend/features/search/src/dimensions/facet-definitions.ts
DimensionBuilderpackages/backend/features/search/src/dimensions/dimension-builder.service.ts
Response mapperpackages/backend/features/search/src/dimensions/dimension-response-mapper.ts
Game attribute resolverpackages/backend/features/search/src/dimensions/game-attribute-resolver.ts
Filter builderspackages/backend/features/search/src/dimensions/filter-builders.ts
Attribute field mappingpackages/backend/features/search/src/dimensions/attribute-field-mapping.ts
Last updated on