search API
Everything the package exports, by the path you import it from. Open an export for its signature and options.
@evolve-framework/search
The document model (ProductDocument, ContentDocument, …), the provider ports (SearchIndexAdapter, SearchQueryAdapter, SearchCapabilities), supportsSort to check a sort against those capabilities, searchWithCorrection to search an adapter's suggestion when a term finds nothing, the query and facet types, and IndexScope
Classes
SearchIndexNotFoundErrorA search index that has not been built yet.
import { SearchIndexNotFoundError } from "@evolve-framework/search";Distinct from a failure, and the difference matters to whoever is on call: it is the ordinary state of a deployment between switching the search engine over and running the first sync. Reported as a generic backend error it sends somebody looking for an outage; named, it says what to run.
Declared here rather than in a provider package so a consumer can recognise it without depending on which engine is behind the adapter.
Extends Error
new SearchIndexNotFoundError(index: string)Functions
isSameScopeTrue when both scopes address the same content.
import { isSameScope } from "@evolve-framework/search";function isSameScope(a: IndexScope, b: IndexScope): boolean| Option | Type | Description |
|---|---|---|
storeKey | string | Tenant/channel this content belongs to (a commercetools store key). |
locales | string[] | Locales to index content for. |
storeKey | string | Tenant/channel this content belongs to (a commercetools store key). |
locales | string[] | Locales to index content for. |
currencies? | string[] | Currencies to resolve prices for. |
countries? | string[] | Countries to resolve tax for, when tax is country-dependent. |
currencies? | string[] | Currencies to resolve prices for. |
countries? | string[] | Countries to resolve tax for, when tax is country-dependent. |
Returns boolean
searchWithCorrectionRuns a search, and where the term as typed finds nothing, searches the top suggestion for it instead and reports it as correctedTerm.
import { searchWithCorrection } from "@evolve-framework/search";The correction is only taken when it finds something, so a shopper sees the
term they typed whenever nothing better exists. Nothing is corrected with
exactTerm, for a term a keyword rule redirects, or where the adapter
corrected the term itself. suggest is the adapter's, and passing none turns
correction off.
function searchWithCorrection<TDoc, TQuery>( search: (query: TQuery) => Promise<SearchResponse<TDoc>>, suggest: Suggester | undefined, query: TQuery,): Promise<SearchResponse<TDoc>>Returns Promise<SearchResponse<TDoc>>
supportsSortWhether an adapter orders by a sort rather than quietly falling back to relevance.
import { supportsSort } from "@evolve-framework/search";function supportsSort( capabilities: Pick<SearchCapabilities, "sorts">, sort: SortSpec | undefined,): boolean| Option | Type | Description |
|---|---|---|
sorts | SortField[] | Sort fields this adapter can order by. |
field | SortField | |
direction | "asc" | "desc" |
Returns boolean
Types
AttributeMap
import { type AttributeMap } from "@evolve-framework/search";export type AttributeMap = Record<string, AttributeValue>;AttributeValueA value an attribute can hold.
import { type AttributeValue } from "@evolve-framework/search";export type AttributeValue = string | number | boolean | string[] | number[] | LocalizedText;AvailabilityDocument
import { type AvailabilityDocument } from "@evolve-framework/search";export type AvailabilityDocument = { inStock: boolean; quantity?: number; deliveryTime?: LocalizedText;};CategoryDocumentOne category in a path.
import { type CategoryDocument } from "@evolve-framework/search";export type CategoryDocument = { id: string; key: string; name: LocalizedText; slug: LocalizedText; description?: LocalizedText;};CategoryFacetResult
import { type CategoryFacetResult } from "@evolve-framework/search";export type CategoryFacetResult = FacetDisplay & { kind: "category"; key: string; label: string; options: CategoryFacetValue[];};CategoryFacetValue
import { type CategoryFacetValue } from "@evolve-framework/search";export type CategoryFacetValue = FacetValue & { children?: CategoryFacetValue[];};CategoryPathOrdered root → leaf.
import { type CategoryPath } from "@evolve-framework/search";export type CategoryPath = CategoryDocument[];CategorySearchQuery
import { type CategorySearchQuery } from "@evolve-framework/search";export type CategorySearchQuery = { scope: QueryScope; term?: string; page?: number; pageSize?: number;};CategorySearchResponse
import { type CategorySearchResponse } from "@evolve-framework/search";export type CategorySearchResponse = { results: CategorySearchResult[]; total: number;};CategorySearchResult
import { type CategorySearchResult } from "@evolve-framework/search";export type CategorySearchResult = { id: string; key: string; name: string; slug: string; path: string;};ContentDocumentA piece of content — a page, an article, a landing page — so a CMS can be indexed into the same engine as a catalog.
import { type ContentDocument } from "@evolve-framework/search";A sibling of ProductDocument rather than a variation on it: the two
share only what PresentableDocument holds. Everything the engine does
— batching, event routing, fan-out cleanup, atomic rebuilds — works on this
unchanged, because none of it reads a commerce field.
type stays open rather than being pinned to one literal: a single CMS
usually models several content types, and each should be able to route to its
own target and its own read-side mapper. Narrow it per type when that helps:
interface ArticleDocument extends ContentDocument {
type: "article";
readingMinutes?: number;
}
export interface ContentDocument extends PresentableDocument { body?: LocalizedText; publishedAt?: string; tags?: string[];}DeleteBySourceOptions
import { type DeleteBySourceOptions } from "@evolve-framework/search";export type DeleteBySourceOptions = { documentTypes: string[]; keep?: string[];};DimensionDocumentA selectable variant axis (colour, size), resolved for one variant.
import { type DimensionDocument } from "@evolve-framework/search";export type DimensionDocument = { key: string; label: LocalizedText; value: string; valueLabel: LocalizedText;};FacetDisplayHow a list of options displays, where the facet configuration says: how many show before the rest fold away, and whether a box searches them.
import { type FacetDisplay } from "@evolve-framework/search";export type FacetDisplay = { visibleOptions?: number; searchable?: boolean;};FacetFilterA user's selection on one facet.
import { type FacetFilter } from "@evolve-framework/search";export type FacetFilter = { key: string; selections?: string[]; min?: number; max?: number; toggle?: boolean;};FacetKindNormalized facet results and filters.
import { type FacetKind } from "@evolve-framework/search";This package defines facet results — the four kinds the GraphQL Facet
union exposes — and the filter input that selects them. It deliberately does
not define a facet configuration language: Algolia configures facets in
its dashboard (renderingContent), the commercetools search provider reads a
facetConfig JSON from a category custom field, and Relewise takes typed
facets per request. Inventing a neutral config language would mean every
adapter faking a dialect its provider does not speak.
Adapters that do want caller-supplied configuration receive it verbatim via
FacetSchema on the query; the rest ignore it.
export type FacetKind = "options" | "range" | "toggle" | "category";FacetResult
import { type FacetResult } from "@evolve-framework/search";export type FacetResult = OptionsFacetResult | ToggleFacetResult | RangeFacetResult | CategoryFacetResult;FacetSchemaProvider-specific facet configuration, passed through from the caller to the adapter untouched.
import { type FacetSchema } from "@evolve-framework/search";export type FacetSchema = Record<string, unknown>;FacetValue
import { type FacetValue } from "@evolve-framework/search";export type FacetValue = { key: string; label: string; count: number; selected: boolean;};FilterConditionA filter applied before faceting, not driven by user selection.
import { type FilterCondition } from "@evolve-framework/search";export type FilterCondition = { key: string; value: string; values?: string[];};ImageDocument
import { type ImageDocument } from "@evolve-framework/search";export type ImageDocument = { url: string; alt?: LocalizedText; width?: number; height?: number;};IndexScopeWhere documents live.
import { type IndexScope } from "@evolve-framework/search";Deliberately not an index name: providers disagree on what a "target" is — an Algolia index (one per store, with a replica per sort order), a Constructor section plus language/region, a Luigi's Box tracker plus type, a Relewise dataset where language and currency are request parameters. Shared code passes a scope; each adapter resolves its own target(s) from it.
export type IndexScope = { storeKey: string; locales: string[]; currencies?: string[]; countries?: string[];};KnownSortFieldSort orders the framework names itself, and that an adapter is expected to understand.
import { type KnownSortField } from "@evolve-framework/search";export type KnownSortField = "relevance" | "price" | "name" | "popularity" | "rating" | "new" | "published";LocalizedTextA localized value keyed by locale, e.g.
import { type LocalizedText } from "@evolve-framework/search";export type LocalizedText = Record<string, string>;OptionsFacetResult
import { type OptionsFacetResult } from "@evolve-framework/search";export type OptionsFacetResult = FacetDisplay & { kind: "options"; key: string; label: string; options: FacetValue[];};PresentableDocumentWhat anything renderable carries, whatever it is.
import { type PresentableDocument } from "@evolve-framework/search";Sits between SearchDocument and the concrete document types so a
non-commerce document — ContentDocument — can share these fields
without inheriting categories, variants and startingPrice. Filling
those with empty arrays would not be merely redundant: an absent value means
"hydrate from the commerce backend" and [] means "genuinely none", so a
content page claiming to have no variants would be making an assertion about
a product it is not.
export interface PresentableDocument extends SearchDocument { key?: string; name: LocalizedText; slug: LocalizedText; description?: LocalizedText; meta?: { title?: LocalizedText; description?: LocalizedText; }; image?: ImageDocument; attributes: AttributeMap; extra?: Record<string, unknown>;}PriceDocumentA price, resolved at index time for one currency (and one country, when tax differs per country).
import { type PriceDocument } from "@evolve-framework/search";Both net and gross are stored rather than derived from taxRate: the
commerce backend owns the rounding rules, so recomputing one from the other
at read time drifts by a cent. taxRate is kept so a Tax value can be
reported without another lookup.
export type PriceDocument = { currency: string; country?: string; net: number; gross: number; taxRate?: number; fractionDigits?: number; list?: { net: number; gross: number; };};ProductDocumentThe standard document shape the built-in mappers understand.
import { type ProductDocument } from "@evolve-framework/search";Extend it with extends, which reports a conflicting member as an error
where an intersection would quietly resolve it to never:
interface BundleDocument extends ProductDocument {
type: "bundle";
components: { sku: string; quantity: number }[];
}
export interface ProductDocument extends PresentableDocument { brand?: string; categories: CategoryPath[]; primaryCategory?: CategoryPath; variants: ProductVariantDocument[]; variantDimensions?: VariantDimensionDocument[]; startingPrice: PriceDocument[]; rating?: number; createdAt?: string;}ProductSearchQueryA query for product documents.
import { type ProductSearchQuery } from "@evolve-framework/search";The two commerce-specific ways of narrowing a search live here rather than on
SearchQuery, so a query for content documents does not carry fields
that mean nothing to it.
export interface ProductSearchQuery extends SearchQuery { categoryId?: string; skus?: string[];}ProductVariantDocument
import { type ProductVariantDocument } from "@evolve-framework/search";export type ProductVariantDocument = { id: string; sku: string; name?: LocalizedText; description?: LocalizedText; ean?: string; hscode?: string; images: ImageDocument[]; prices: PriceDocument[]; attributes: AttributeMap; dimensions?: DimensionDocument[]; availability?: AvailabilityDocument; ribbon?: RibbonDocument; quantityRule?: QuantityRuleDocument;};QuantityRuleDocumentWhat a variant allows on a cart line, without stock: the bounds and step a quantity stepper uses, resolved at index time from the commerce backend.
import { type QuantityRuleDocument } from "@evolve-framework/search";export type QuantityRuleDocument = { minimum: number; maximum?: number; step: number;};QueryScopeThe scope of a single query.
import { type QueryScope } from "@evolve-framework/search";export type QueryScope = { storeKey: string; locale: string; currency?: string; country?: string;};RangeFacetResult
import { type RangeFacetResult } from "@evolve-framework/search";export type RangeFacetResult = { kind: "range"; key: string; label: string; currency?: string; min: number; max: number; selectedMin?: number; selectedMax?: number;};ReindexSessionAn in-flight full rebuild.
import { type ReindexSession } from "@evolve-framework/search";Atomicity is per target, not across them. A session spanning several document
types swaps one target at a time, and a provider that cannot swap several
indices in one operation — Algolia cannot — will make the first live before
the last. If a later swap fails, abort() cannot take back the ones already
committed, so the targets are left holding different snapshots until the next
successful rebuild. Callers that need all-or-nothing across types have to
reconcile it themselves.
export type ReindexSession<TDoc extends SearchDocument = ProductDocument> = { upsert(documents: TDoc[]): Promise<void>; commit(): Promise<void>; abort(): Promise<void>;};RibbonDocumentA badge on a product card — a sticker, "SALE", a discount's name.
import { type RibbonDocument } from "@evolve-framework/search";Worth indexing because a listing asks for it on every card, and resolving it live costs a read of the whole product. The trade is freshness: a ribbon derived from a discount changes when the discount changes, which is not a product event, so a catalog driving ribbons from discounts needs those changes to trigger a resync.
export type RibbonDocument = { kind: string; label: LocalizedText;};SearchAdapterA provider class that implements both halves.
import { type SearchAdapter } from "@evolve-framework/search";export type SearchAdapter<TDoc extends SearchDocument = ProductDocument, TQuery extends SearchQuery = ProductSearchQuery> = SearchIndexAdapter<TDoc> & SearchQueryAdapter<TDoc, TQuery>;SearchCapabilitiesWhat an adapter can actually do.
import { type SearchCapabilities } from "@evolve-framework/search";export type SearchCapabilities = { categorySearch: boolean; suggestions: boolean; fullReindex: boolean; deleteBySource: boolean; personalization: boolean; facets: FacetKind[]; sorts: SortField[]; maxResults?: number;};SearchDocumentThe minimum every document must carry.
import { type SearchDocument } from "@evolve-framework/search";export interface SearchDocument { id: string; type: string; sourceId: string;}SearchIndexAdapterThe write port: everything needed to get documents into a search provider.
import { type SearchIndexAdapter } from "@evolve-framework/search";Kept separate from SearchQueryAdapter so an indexer deployment never
needs search credentials and an API deployment never needs write keys. A
provider package may still export one class implementing both.
Implementations throw on failure. Swallowing errors here acks the queue message that triggered the write, which loses the update silently.
export type SearchIndexAdapter<TDoc extends SearchDocument = ProductDocument> = { readonly identifier: string; readonly capabilities: SearchCapabilities; readonly batchSize: number; init(): Promise<void>; close?(): Promise<void>; upsertDocuments(scope: IndexScope, documents: TDoc[]): Promise<void>; deleteDocuments(scope: IndexScope, ids: string[], documentTypes: string[]): Promise<void>; deleteBySource?(scope: IndexScope, sourceIds: string[], options: DeleteBySourceOptions): Promise<void>; beginFullReindex?(scope: IndexScope, documentTypes: string[]): Promise<ReindexSession<TDoc>>; applySettings?(scope: IndexScope, settings: unknown): Promise<void>;};SearchQueryEverything an adapter needs to run a query, for any kind of document.
import { type SearchQuery } from "@evolve-framework/search";export interface SearchQuery { scope: QueryScope; term?: string; documentTypes?: string[]; filters?: FacetFilter[]; prefilters?: FilterCondition[]; sort?: SortSpec; page?: number; pageSize?: number; session?: SearchSession; facetSchema?: FacetSchema; facets?: boolean; exactTerm?: boolean;}SearchQueryAdapterThe read port.
import { type SearchQueryAdapter } from "@evolve-framework/search";export type SearchQueryAdapter<TDoc extends SearchDocument = ProductDocument, TQuery extends SearchQuery = ProductSearchQuery> = { readonly identifier: string; readonly capabilities: SearchCapabilities; init(): Promise<void>; close?(): Promise<void>; search(query: TQuery): Promise<SearchResponse<TDoc>>; searchCategories?(query: CategorySearchQuery): Promise<CategorySearchResponse>; suggest?(query: SuggestQuery): Promise<SuggestResponse>;};SearchRedirectA keyword rule's destination: a page of the site by its path, or a URL.
import { type SearchRedirect } from "@evolve-framework/search";export type SearchRedirect = { kind: "page" | "url"; path: string;};SearchResponse
import { type SearchResponse } from "@evolve-framework/search";export type SearchResponse<TDoc extends SearchDocument = ProductDocument> = { hits: TDoc[]; total: number; facets: FacetResult[]; queryId?: string; correctedTerm?: string; redirect?: SearchRedirect; raw?: unknown;};SearchSessionSignals used for personalization and analytics.
import { type SearchSession } from "@evolve-framework/search";export type SearchSession = { userId?: string; anonymousId?: string; ip?: string; userAgent?: string;};SortFieldA field to sort on.
import { type SortField } from "@evolve-framework/search";Open rather than a closed union, so a consumer can sort on something the
framework does not name — a content document by publishedAt, a product by a
custom score — without a change here. The known fields still autocomplete.
An adapter that receives a field it does not handle should fall back to relevance rather than fail: index configuration decides which sorts exist (Algolia needs a replica per order), so the query side cannot validate it.
export type SortField = KnownSortField | (string & Record<never, never>);SortSpec
import { type SortSpec } from "@evolve-framework/search";export type SortSpec = { field: SortField; direction: "asc" | "desc";};Suggestion
import { type Suggestion } from "@evolve-framework/search";export type Suggestion = { text: string; count?: number;};SuggestQuery
import { type SuggestQuery } from "@evolve-framework/search";export type SuggestQuery = { scope: QueryScope; term: string; limit?: number;};SuggestResponse
import { type SuggestResponse } from "@evolve-framework/search";export type SuggestResponse = { suggestions: Suggestion[];};ToggleFacetResult
import { type ToggleFacetResult } from "@evolve-framework/search";export type ToggleFacetResult = { kind: "toggle"; key: string; label: string; options: FacetValue[];};VariantDimensionDocumentA selectable axis across a product's variants (colour, size), precomputed at index time.
import { type VariantDimensionDocument } from "@evolve-framework/search";Only the matrix is indexed — which values exist and which variants carry them. Which option is selected, which variant an option navigates to, and whether a combination is unavailable all depend on the variant the shopper is looking at, so they are derived at read time by intersecting these sets. That keeps the document independent of the request while still needing no call back to the commerce backend.
export type VariantDimensionDocument = { name: string; label: LocalizedText; type: "text" | "color" | "image"; values: VariantDimensionValueDocument[];};VariantDimensionValueDocument
import { type VariantDimensionValueDocument } from "@evolve-framework/search";export type VariantDimensionValueDocument = { value: string; label: LocalizedText; color?: string; variants: VariantRef[];};VariantRefIdentifies one variant, possibly of another product.
import { type VariantRef } from "@evolve-framework/search";export type VariantRef = { key: string; productId: string; variantId: string; sku: string;};@evolve-framework/search/indexing
SearchIndexerModule, the IndexSyncService it runs, the DocumentSource port and defaultEventRouting
Classes
IndexSyncServiceThe routine every search integration repeats: resolve the scopes, build the documents for an entity in each one, push them in provider-sized batches, and remove what should no longer be there.
import { IndexSyncService } from "@evolve-framework/search/indexing";It is deliberately unaware of both the commerce backend (behind
DocumentSource) and the search provider (behind
SearchIndexAdapter), so the same engine drives commercetools →
Algolia and anything else.
new IndexSyncService(options: IndexSyncServiceOptions<TDoc>)| Option | Type | Description |
|---|---|---|
source | DocumentSource<TDoc> | Where documents come from: the commerce engine's catalog source. |
adapter | SearchIndexAdapter<TDoc> | Where documents go: the search provider's index adapter. |
events? | CatalogEventRouting | Which event types trigger which action. Defaults to |
| Method | Description |
|---|---|
init(): Promise<void> | |
syncEntity(sourceId: string): Promise<void> | Rebuild every document for one entity, in every scope, along with any entity whose documents embed data from it. |
deleteEntity(sourceId: string): Promise<void> | Remove every document derived from an entity, in every scope, then refresh anything that embedded data from it. |
reindex(
options?: { scopes?: IndexScope[] | undefined; },
): Promise<ReindexReport> | Rebuild a target from scratch. |
handler(): MessageHandler<CatalogEvent> | A MessageHandler for catalog events, ready to hand to createSQSHandler and friends. |
SearchIndexerModuleHosts a catalog → search sync as a Domain Service.
import { SearchIndexerModule } from "@evolve-framework/search/indexing";The same service usually also consumes catalog events off a queue; use
SearchIndexerModule.handler for that, which is the platform-neutral
MessageHandler the cloud adapters wrap (createSQSHandler and friends).
A reindex is triggered rather than awaited: rebuilding a catalog takes far longer than a request, so the route returns immediately and the result is logged.
Extends AbstractModule<unknown>
new SearchIndexerModule(options: SearchIndexerModuleOptions<TDoc>)| Option | Type | Description |
|---|---|---|
source | DocumentSource<TDoc> | Where documents come from: the commerce engine's catalog source. |
adapter | SearchIndexAdapter<TDoc> | Where documents go: the search provider's index adapter. |
routes? | boolean | Publish the operational routes ( |
secret? | string | Shared secret required on the operational routes. Callers must present
MoreRequired whenever the routes are published — see
|
allowUnauthenticatedRoutes? | boolean | Publish the operational routes with no secret at all. More
|
events? | CatalogEventRouting | Which event types trigger which action. Defaults to
|
| Method | Description |
|---|---|
handler(): MessageHandler<CatalogEvent> | Catalog events → sync. |
Constants
defaultEventRouting
import { defaultEventRouting } from "@evolve-framework/search/indexing";const defaultEventRouting: CatalogEventRoutingTypes
CatalogEventA catalog event as published by the commerce backend.
import { type CatalogEvent } from "@evolve-framework/search/indexing";export type CatalogEvent = { type: string; id: string;};CatalogEventRouting
import { type CatalogEventRouting } from "@evolve-framework/search/indexing";export type CatalogEventRouting = { upsert: string[]; delete: string[];};DocumentSourceWhere documents come from.
import { type DocumentSource } from "@evolve-framework/search/indexing";export type DocumentSource<TDoc extends SearchDocument = ProductDocument> = { getScopes(): Promise<IndexScope[]>; getDocuments(scope: IndexScope, sourceId: string): Promise<TDoc[]>; iterate(scope: IndexScope): AsyncIterable<TDoc[]>; readonly fansOut: boolean; readonly documentTypes: string[]; getAffectedEntities?(sourceId: string): Promise<string[]>;};IndexSyncServiceOptions
import { type IndexSyncServiceOptions } from "@evolve-framework/search/indexing";export type IndexSyncServiceOptions<TDoc extends SearchDocument> = { source: DocumentSource<TDoc>; adapter: SearchIndexAdapter<TDoc>; events?: CatalogEventRouting;};ReindexReport
import { type ReindexReport } from "@evolve-framework/search/indexing";export type ReindexReport = { scopes: ScopeReindexReport[]; documents: number;};ScopeReindexReport
import { type ScopeReindexReport } from "@evolve-framework/search/indexing";export type ScopeReindexReport = { scope: IndexScope; documents: number; atomic: boolean;};SearchIndexerModuleOptions
import { type SearchIndexerModuleOptions } from "@evolve-framework/search/indexing";export type SearchIndexerModuleOptions<TDoc extends SearchDocument> = { source: DocumentSource<TDoc>; adapter: SearchIndexAdapter<TDoc>; routes?: boolean; secret?: string; allowUnauthenticatedRoutes?: boolean; events?: CatalogEventRouting;};Not listed: 2 options types. They appear as the tables under the constructors and functions they type.

