The Search module is a provider abstraction: it owns no data models and wraps exactly one registered search provider behind three verbs — index, remove, search. Mercur ships a default in-process provider built on Orama that requires zero external infrastructure; operators can swap in Algolia, Meilisearch, or anything else by implementing the same contract.
Both products (master catalog entries) and offers (per-seller listings with offer-scoped prices) are indexed.
Document shape
interface SearchDoc {
id: string
type: "product" | "offer"
title: string
description?: string
handle: string
thumbnail?: string
seller_handle?: string // offers only
collection_id?: string
collection?: string
category_ids?: string[]
categories?: string[]
product_id?: string // offers
variant_id?: string // offers
sku?: string // offers
attribute_tokens: string[] // facetable: "attr:<handle>:<value_id>"
attributes: Record<string, string> // stored label map
prices: Record<string, SearchDocPrice> // keyed by region_id, stored not indexed
}
Prices are stored, not searched: each document carries a per-region price map (calculated_amount, original_amount, currency_code), and the provider projects calculated_price for the requested region at query time. Suspended sellers’ offers and unpublished products are excluded at index time. Attributes flagged is_filterable are tokenized into attribute_tokens for faceting.
Query and results
interface SearchQueryBase {
q?: string
limit?: number
offset?: number
context?: Record<string, unknown> // pricing/display context, not a filter
filters?: Record<string, unknown> // provider-owned shape
}
interface SearchResults {
hits: SearchDoc[]
count: number
facets: { collections; categories; attributes } // pre-labelled by the provider
}
filters is an open passthrough — each provider defines its own filter shape. The built-in Orama provider accepts:
filters?: {
type?: "product" | "offer"
collection_ids?: string[]
category_ids?: string[]
seller_handle?: string
attributes?: Record<string, string[]> // attribute handle -> selected value ids
}
Service
SearchModuleService exposes:
| Method | Behavior |
|---|
index(docs) | Upserts documents into the provider index |
remove(ids) | Removes documents by ID |
search(query) | Runs a query against the provider |
getProviderIdentifier() | Returns the active provider’s identifier |
Provider contract
Implement AbstractSearchProvider (exported from the module) and register it via the module’s provider option. Exactly one provider must be registered — the module throws at boot otherwise. If no provider is configured, the Orama provider registers automatically.
abstract class AbstractSearchProvider<TQuery extends SearchQueryBase> {
static identifier: string
abstract index(docs: SearchDoc[]): Promise<void>
abstract remove(ids: string[]): Promise<void>
abstract search(query: TQuery): Promise<SearchResults>
}
The built-in Orama provider holds its index in process memory. It starts
empty, is rebuilt on boot via the reindex event, and does not share state
across processes. For multi-instance deployments, use an external provider.
Index synchronization
Sync is event-driven — there are no search workflows:
- Subscribers react to product, offer, and seller changes (
search-product-changed, search-product-deleted, search-offer-changed, search-seller-changed) and reindex or remove the affected documents.
- A full rebuild runs when the
search.reindex event (SEARCH_REINDEX_EVENT) is emitted; in worker mode the module emits it automatically on application start.
POST /store/search — the storefront query route. Body: q, limit (default 12, max 100), offset, region_id / country_code / province (pricing context), and the provider-owned filters. Responds with { hits, count, limit, offset, facets }. Customer authentication is optional.
Next steps