diff --git a/apps/herbatika/src/components/search/search-autocomplete-media.tsx b/apps/herbatika/src/components/search/search-autocomplete-media.tsx
index fd594d82a8..ca495fde4c 100644
--- a/apps/herbatika/src/components/search/search-autocomplete-media.tsx
+++ b/apps/herbatika/src/components/search/search-autocomplete-media.tsx
@@ -13,6 +13,7 @@ const TYPE_ICON: Record<
> = {
brand: "token-icon-label",
category: "token-icon-box",
+ content: "token-icon-file-document",
}
export function SearchAutocompleteMedia({
diff --git a/apps/herbatika/src/components/search/search-autocomplete-sections.ts b/apps/herbatika/src/components/search/search-autocomplete-sections.ts
index be5f234c29..c6aefbad3f 100644
--- a/apps/herbatika/src/components/search/search-autocomplete-sections.ts
+++ b/apps/herbatika/src/components/search/search-autocomplete-sections.ts
@@ -4,6 +4,7 @@ import type { SearchAutocompletePanelSection } from "./search-autocomplete-panel
type SearchAutocompleteSectionTitles = {
brands: string
categories: string
+ content: string
products: string
}
@@ -14,6 +15,7 @@ export const createSearchAutocompleteSections = (
{ key: "product", title: titles.products, items: data.products },
{ key: "category", title: titles.categories, items: data.categories },
{ key: "brand", title: titles.brands, items: data.brands },
+ { key: "content", title: titles.content, items: data.content },
]
export const clampSearchAutocompleteIndex = (
diff --git a/apps/herbatika/src/components/search/search-entity-results.tsx b/apps/herbatika/src/components/search/search-entity-results.tsx
new file mode 100644
index 0000000000..cc1f667592
--- /dev/null
+++ b/apps/herbatika/src/components/search/search-entity-results.tsx
@@ -0,0 +1,94 @@
+'use client'
+
+import { Link } from '@techsio/ui-kit/atoms/link'
+import NextLink from 'next/link'
+import type { SearchAutocompleteSuggestion, SearchAutocompleteResponse } from '@/lib/search-autocomplete/search-autocomplete-types'
+import { SearchAutocompleteMedia } from './search-autocomplete-media'
+
+type SearchEntityResultSectionProps = {
+ items: SearchAutocompleteSuggestion[]
+ title: string
+}
+
+type SearchEntityResultsProps = Pick
+
+function SearchEntityResultSection({ items, title }: SearchEntityResultSectionProps) {
+ if (items.length === 0) {
+ return null
+ }
+
+ return (
+
+
+ { title }
+
+
+
+ {
+ items.map((item) => (
+
+
+
+
+
+
+ { item.title }
+
+
+ {
+ item.subtitle ? (
+
+ { item.subtitle }
+
+ ) : null
+ }
+
+
+
+ ))
+ }
+
+
+ )
+}
+
+export function SearchEntityResults({ brands, categories, content }: SearchEntityResultsProps) {
+ if (brands.length + categories.length + content.length === 0) {
+ return null
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/herbatika/src/components/search/use-search-autocomplete-controller.ts b/apps/herbatika/src/components/search/use-search-autocomplete-controller.ts
index d27426ad0d..330218bb82 100644
--- a/apps/herbatika/src/components/search/use-search-autocomplete-controller.ts
+++ b/apps/herbatika/src/components/search/use-search-autocomplete-controller.ts
@@ -44,6 +44,7 @@ export function useSearchAutocompleteController({
const autocomplete = useSearchAutocomplete({
countryCode,
currencyCode,
+ enabled: isFocused && !isDismissed,
query: value,
regionId,
})
@@ -51,14 +52,18 @@ export function useSearchAutocompleteController({
const sections = createSearchAutocompleteSections(autocomplete.data, {
brands: t("autocomplete.sections.brands"),
categories: t("autocomplete.sections.categories"),
+ content: t("autocomplete.sections.content"),
products: t("autocomplete.sections.products"),
})
const flatItems = sections.flatMap((section) => section.items)
const activeItem = flatItems[activeIndex] ?? null
+ const hasSearchableQuery =
+ normalizedQuery.length === 0 ||
+ normalizedQuery.length >= SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH
const shouldShowPanel =
isFocused &&
!isDismissed &&
- normalizedQuery.length >= SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH &&
+ hasSearchableQuery &&
autocomplete.status !== "idle"
const activeItemId =
shouldShowPanel && activeItem
diff --git a/apps/herbatika/src/components/search/use-search-autocomplete.ts b/apps/herbatika/src/components/search/use-search-autocomplete.ts
index bbe071eecc..eeb1b96ca6 100644
--- a/apps/herbatika/src/components/search/use-search-autocomplete.ts
+++ b/apps/herbatika/src/components/search/use-search-autocomplete.ts
@@ -14,6 +14,7 @@ type UseSearchAutocompleteInput = {
countryCode?: string
query: string
currencyCode: string
+ enabled: boolean
regionId?: string
}
@@ -26,6 +27,7 @@ export function useSearchAutocomplete({
countryCode,
query,
currencyCode,
+ enabled,
regionId,
}: UseSearchAutocompleteInput): UseSearchAutocompleteResult {
const normalizedQuery = query
@@ -37,7 +39,16 @@ export function useSearchAutocomplete({
const [status, setStatus] = useState("idle")
useEffect(() => {
- if (normalizedQuery.length < SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH) {
+ if (!enabled) {
+ setData(createEmptySearchAutocompleteResponse(normalizedQuery))
+ setStatus("idle")
+ return
+ }
+
+ if (
+ normalizedQuery.length > 0 &&
+ normalizedQuery.length < SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH
+ ) {
setData(createEmptySearchAutocompleteResponse(normalizedQuery))
setStatus("idle")
return
@@ -90,7 +101,7 @@ export function useSearchAutocomplete({
window.clearTimeout(timeoutId)
abortController.abort()
}
- }, [countryCode, currencyCode, normalizedQuery, regionId])
+ }, [countryCode, currencyCode, enabled, normalizedQuery, regionId])
return { data, status }
}
diff --git a/apps/herbatika/src/components/search/use-search-listing-controller.ts b/apps/herbatika/src/components/search/use-search-listing-controller.ts
index 03ab98f3d6..f0df671c27 100644
--- a/apps/herbatika/src/components/search/use-search-listing-controller.ts
+++ b/apps/herbatika/src/components/search/use-search-listing-controller.ts
@@ -105,5 +105,7 @@ export function useSearchListingController() {
products: catalogQuery.products,
productsCurrencyCode: regionCurrencyCode,
query,
+ searchCountryCode: region?.country_code,
+ searchRegionId: region?.region_id,
}
}
diff --git a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-content-normalizers.ts b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-content-normalizers.ts
new file mode 100644
index 0000000000..3d7ba24f60
--- /dev/null
+++ b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-content-normalizers.ts
@@ -0,0 +1,23 @@
+import { normalizeString } from './search-autocomplete-normalizers'
+import type { RawSearchAutocompleteContentHit, SearchAutocompleteSuggestion } from './search-autocomplete-types'
+
+const createContentSuggestion = (hit: RawSearchAutocompleteContentHit): SearchAutocompleteSuggestion | null => {
+ const id = normalizeString(hit.id)
+ const title = normalizeString(hit.title)
+ const href = normalizeString(hit.href)
+ const type = normalizeString(hit.type)
+
+ if (!id || !title || !href) {
+ return null
+ }
+
+ return {
+ id: id,
+ type: 'content',
+ title: title,
+ href: href,
+ subtitle: type === 'article' ? 'Článok' : normalizeString(hit.excerpt) || 'Informačná stránka'
+ }
+}
+
+export const createContentSuggestions = (hits: RawSearchAutocompleteContentHit[]) => hits.map(createContentSuggestion).filter((item): item is SearchAutocompleteSuggestion => Boolean(item))
diff --git a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-product-normalizers.ts b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-product-normalizers.ts
index a0685be769..d6573acc69 100644
--- a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-product-normalizers.ts
+++ b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-product-normalizers.ts
@@ -36,13 +36,16 @@ const createProductSuggestion = (
currencyCode: HerbatikaCurrencyCode
): SearchAutocompleteSuggestion | null => {
const id = normalizeString(hit.id)
- const title = normalizeString(hit.title)
+ const productTitle = normalizeString(hit.title)
const handle = normalizeString(hit.handle)
- if (!(id && title && handle)) {
+ if (!(id && productTitle && handle)) {
return null
}
+ const variantId = normalizeString(hit.search_result?.variant_id)
+ const variantTitle = normalizeString(hit.search_result?.variant_title)
+ const title = variantTitle ? `${productTitle} – ${variantTitle}` : productTitle
const brandTitle = normalizeString(hit.brand?.title)
const firstCategory = hit.categories?.find((category) =>
Boolean(normalizeString(category.name))
@@ -51,10 +54,10 @@ const createProductSuggestion = (
const price = resolveProductPrice(hit, currencyCode)
return {
- id,
+ id: variantId ? `${id}-${variantId}` : id,
type: "product",
title,
- href: `/p/${handle}`,
+ href: `/p/${handle}${variantId ? `?variant=${encodeURIComponent(variantId)}` : ""}`,
subtitle: [brandTitle, categoryName].filter(Boolean).join(" | "),
imageUrl: normalizeString(hit.thumbnail) || undefined,
priceLabel: price
@@ -67,7 +70,7 @@ const createProductSuggestion = (
export const createProductSuggestions = (
hits: RawSearchAutocompleteProductHit[],
currencyCode: HerbatikaCurrencyCode,
- limit: number
+ limit = hits.length
) =>
hits
.map((hit) => createProductSuggestion(hit, currencyCode))
diff --git a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-taxonomy-normalizers.ts b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-taxonomy-normalizers.ts
index 576d7346c6..611427febe 100644
--- a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-taxonomy-normalizers.ts
+++ b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-taxonomy-normalizers.ts
@@ -1,30 +1,15 @@
import { createBrandHref } from "@/lib/storefront/brands"
import {
createHandleLabel,
- normalizeComparable,
normalizeString,
resolveBrandSlug,
} from "./search-autocomplete-normalizers"
import type {
RawSearchAutocompleteBrandRef,
RawSearchAutocompleteCategoryRef,
- RawSearchAutocompleteFacetItem,
- RawSearchAutocompleteProductHit,
SearchAutocompleteSuggestion,
} from "./search-autocomplete-types"
-const matchesQuery = (values: unknown[], query: string) => {
- const comparableQuery = normalizeComparable(query)
- return values.some((value) =>
- normalizeComparable(normalizeString(value)).includes(comparableQuery)
- )
-}
-
-const categoryMatchesQuery = (
- category: RawSearchAutocompleteCategoryRef,
- query: string
-) => matchesQuery([category.name, category.handle], query)
-
const createCategorySuggestion = (
category: RawSearchAutocompleteCategoryRef
): SearchAutocompleteSuggestion | null => {
@@ -45,41 +30,13 @@ const createCategorySuggestion = (
}
export const createCategorySuggestions = ({
- productHits,
- query,
- limit,
+ categoryHits,
}: {
- productHits: RawSearchAutocompleteProductHit[]
- query: string
- limit: number
-}) => {
- const suggestions: SearchAutocompleteSuggestion[] = []
- const seen = new Set()
-
- const pushSuggestion = (suggestion: SearchAutocompleteSuggestion | null) => {
- if (!suggestion || seen.has(suggestion.href)) {
- return
- }
-
- seen.add(suggestion.href)
- suggestions.push(suggestion)
- }
-
- for (const product of productHits) {
- for (const category of product.categories ?? []) {
- if (categoryMatchesQuery(category, query)) {
- pushSuggestion(createCategorySuggestion(category))
- }
- }
- }
-
- return suggestions.slice(0, limit)
-}
-
-const brandMatchesQuery = (
- brand: RawSearchAutocompleteBrandRef,
- query: string
-) => matchesQuery([brand.title, brand.handle], query)
+ categoryHits: RawSearchAutocompleteCategoryRef[]
+}) =>
+ categoryHits
+ .map(createCategorySuggestion)
+ .filter((item): item is SearchAutocompleteSuggestion => Boolean(item))
const createBrandSuggestion = (
brand: RawSearchAutocompleteBrandRef
@@ -101,74 +58,11 @@ const createBrandSuggestion = (
}
}
-const createBrandSuggestionFromFacet = (
- facet: RawSearchAutocompleteFacetItem
-): SearchAutocompleteSuggestion | null => {
- const id = normalizeString(facet.id)
- const title = normalizeString(facet.label)
- const slug = id.startsWith("brand-")
- ? id.slice("brand-".length)
- : resolveBrandSlug(id, title)
-
- if (!(id && title && slug)) {
- return null
- }
-
- return {
- id,
- type: "brand",
- title,
- href: createBrandHref({ slug }),
- }
-}
-
-const pushUniqueSuggestion = (
- suggestions: SearchAutocompleteSuggestion[],
- seen: Set,
- suggestion: SearchAutocompleteSuggestion | null
-) => {
- if (!suggestion || seen.has(suggestion.href)) {
- return
- }
-
- seen.add(suggestion.href)
- suggestions.push(suggestion)
-}
-
export const createBrandSuggestions = ({
- brandFacets,
- productHits,
- query,
- limit,
+ brandHits,
}: {
- brandFacets: RawSearchAutocompleteFacetItem[]
- productHits: RawSearchAutocompleteProductHit[]
- query: string
- limit: number
-}) => {
- const suggestions: SearchAutocompleteSuggestion[] = []
- const seen = new Set()
-
- for (const facet of brandFacets) {
- if (!matchesQuery([facet.id, facet.label], query)) {
- continue
- }
-
- pushUniqueSuggestion(
- suggestions,
- seen,
- createBrandSuggestionFromFacet(facet)
- )
- }
-
- for (const product of productHits) {
- const brand = product.brand
- if (!(brand && brandMatchesQuery(brand, query))) {
- continue
- }
-
- pushUniqueSuggestion(suggestions, seen, createBrandSuggestion(brand))
- }
-
- return suggestions.slice(0, limit)
-}
+ brandHits: RawSearchAutocompleteBrandRef[]
+}) =>
+ brandHits
+ .map(createBrandSuggestion)
+ .filter((item): item is SearchAutocompleteSuggestion => Boolean(item))
diff --git a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-types.ts b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-types.ts
index 650077c292..f90baab84e 100644
--- a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-types.ts
+++ b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete-types.ts
@@ -1,4 +1,8 @@
-export type SearchAutocompleteSuggestionType = "product" | "category" | "brand"
+export type SearchAutocompleteSuggestionType =
+ | "product"
+ | "category"
+ | "brand"
+ | "content"
export type SearchAutocompleteSuggestion = {
id: string
@@ -16,6 +20,7 @@ export type SearchAutocompleteResponse = {
products: SearchAutocompleteSuggestion[]
categories: SearchAutocompleteSuggestion[]
brands: SearchAutocompleteSuggestion[]
+ content: SearchAutocompleteSuggestion[]
}
export type SearchAutocompleteStatus = "idle" | "loading" | "success" | "error"
@@ -38,6 +43,14 @@ export type RawSearchAutocompleteBrandRef = {
handle?: unknown
}
+export type RawSearchAutocompleteContentHit = {
+ id?: unknown
+ type?: unknown
+ title?: unknown
+ excerpt?: unknown
+ href?: unknown
+}
+
export type RawSearchAutocompleteCalculatedPrice = {
calculated_amount?: unknown
currency_code?: unknown
@@ -49,9 +62,19 @@ export type RawSearchAutocompleteProductHit = {
handle?: unknown
thumbnail?: unknown
metadata?: unknown
+ search_result?: {
+ variant_id?: unknown
+ variant_title?: unknown
+ }
brand?: RawSearchAutocompleteBrandRef
categories?: RawSearchAutocompleteCategoryRef[]
variants?: Array<{
+ id?: unknown
+ title?: unknown
+ sku?: unknown
+ ean?: unknown
+ upc?: unknown
+ barcode?: unknown
calculated_price?: RawSearchAutocompleteCalculatedPrice
}>
}
@@ -67,4 +90,5 @@ export const createEmptySearchAutocompleteResponse = (
products: [],
categories: [],
brands: [],
+ content: [],
})
diff --git a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete.server.ts b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete.server.ts
index 3b6212759b..fff1244b99 100644
--- a/apps/herbatika/src/lib/search-autocomplete/search-autocomplete.server.ts
+++ b/apps/herbatika/src/lib/search-autocomplete/search-autocomplete.server.ts
@@ -6,6 +6,7 @@ import {
MEDUSA_PUBLISHABLE_KEY,
} from "@/lib/storefront/ssr/constants"
import { normalizeString } from "./search-autocomplete-normalizers"
+import { createContentSuggestions } from "./search-autocomplete-content-normalizers"
import { createProductSuggestions } from "./search-autocomplete-product-normalizers"
import {
createBrandSuggestions,
@@ -13,7 +14,9 @@ import {
} from "./search-autocomplete-taxonomy-normalizers"
import {
createEmptySearchAutocompleteResponse,
- type RawSearchAutocompleteFacetItem,
+ type RawSearchAutocompleteBrandRef,
+ type RawSearchAutocompleteCategoryRef,
+ type RawSearchAutocompleteContentHit,
type RawSearchAutocompleteProductHit,
SEARCH_AUTOCOMPLETE_MAX_QUERY_LENGTH,
SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH,
@@ -21,9 +24,9 @@ import {
} from "./search-autocomplete-types"
type CatalogAutocompleteResponse = {
- facets?: {
- brand?: RawSearchAutocompleteFacetItem[]
- }
+ brands?: RawSearchAutocompleteBrandRef[]
+ categories?: RawSearchAutocompleteCategoryRef[]
+ content?: RawSearchAutocompleteContentHit[]
products?: RawSearchAutocompleteProductHit[]
}
@@ -34,10 +37,6 @@ type FetchSearchAutocompleteInput = {
regionId?: string | null
}
-const PRODUCT_LIMIT = 5
-const CATEGORY_LIMIT = 5
-const BRAND_LIMIT = 4
-const CANDIDATE_LIMIT = 12
const CATALOG_FETCH_TIMEOUT_MS = 3000
const normalizeSearchAutocompleteQuery = (query: string) =>
@@ -54,11 +53,8 @@ const createCatalogAutocompleteUrl = ({
query: string
regionId?: string | null
}) => {
- const url = new URL("/store/catalog/products", MEDUSA_BACKEND_URL)
+ const url = new URL("/store/search/autocomplete", MEDUSA_BACKEND_URL)
url.searchParams.set("q", query)
- url.searchParams.set("page", "1")
- url.searchParams.set("limit", String(CANDIDATE_LIMIT))
- url.searchParams.set("sort", "recommended")
url.searchParams.set("currency_code", currencyCode.toLowerCase())
const normalizedRegionId = normalizeString(regionId)
@@ -138,7 +134,10 @@ export const fetchSearchAutocomplete = async ({
regionId,
}: FetchSearchAutocompleteInput): Promise => {
const normalizedQuery = normalizeSearchAutocompleteQuery(query)
- if (normalizedQuery.length < SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH) {
+ if (
+ normalizedQuery.length > 0 &&
+ normalizedQuery.length < SEARCH_AUTOCOMPLETE_MIN_QUERY_LENGTH
+ ) {
return createEmptySearchAutocompleteResponse(normalizedQuery)
}
@@ -153,21 +152,13 @@ export const fetchSearchAutocomplete = async ({
return {
query: normalizedQuery,
- products: createProductSuggestions(
- productHits,
- safeCurrencyCode,
- PRODUCT_LIMIT
- ),
+ products: createProductSuggestions(productHits, safeCurrencyCode),
categories: createCategorySuggestions({
- productHits,
- query: normalizedQuery,
- limit: CATEGORY_LIMIT,
+ categoryHits: catalogResponse.categories ?? [],
}),
brands: createBrandSuggestions({
- brandFacets: catalogResponse.facets?.brand ?? [],
- productHits,
- query: normalizedQuery,
- limit: BRAND_LIMIT,
+ brandHits: catalogResponse.brands ?? [],
}),
+ content: createContentSuggestions(catalogResponse.content ?? []),
}
}
diff --git a/apps/medusa-be/package.json b/apps/medusa-be/package.json
index dddc415f7e..5445ab6d30 100644
--- a/apps/medusa-be/package.json
+++ b/apps/medusa-be/package.json
@@ -49,6 +49,7 @@
"seedDefaultData": "medusa exec ./src/scripts/seed-products-from-db.ts",
"seedDefaultDataTs": "medusa exec ./src/scripts/seed-products-from-db.ts",
"addInitialSearchDocuments": "medusa exec ./src/scripts/search-index.ts",
+ "rebuildSearchDocuments": "medusa exec ./src/scripts/search-index.ts -- --full",
"setInitialPublishableKey": "bash -euo pipefail -c 'PUBLISHABLE_KEY_TITLE=\"${INITIAL_PUBLISHABLE_KEY_NAME:-Storefront Publishable Key}\"; if [ -f ./src/scripts/output-publishable-key.ts ]; then medusa exec ./src/scripts/output-publishable-key.ts \"$PUBLISHABLE_KEY_TITLE\"; else medusa exec ./src/scripts/output-publishable-key.js \"$PUBLISHABLE_KEY_TITLE\"; fi | grep -o \".* \" | sed \"s///g;s/<\\\\/PK_TOKEN>//g\" | tr -d \"\\n\\r\"'",
"setInitialPublishableKeyZerops": "bash -euo pipefail -c \"npm run -s setInitialPublishableKey | tr -d '\\n\\r' | zsc setSecretEnv CHANNEL_PUBLISHABLE_KEY\"",
"seedHerbatica": "medusa exec ./src/scripts/herbatica-seed.ts",
diff --git a/apps/medusa-be/src/admin/lib/search-profiles.ts b/apps/medusa-be/src/admin/lib/search-profiles.ts
new file mode 100644
index 0000000000..0cc2a26ddb
--- /dev/null
+++ b/apps/medusa-be/src/admin/lib/search-profiles.ts
@@ -0,0 +1,177 @@
+import { sdk as medusaClient } from './sdk'
+
+export type SearchIndexType = 'product' | 'category' | 'brand' | 'content'
+export type SearchSyncMode = 'normal' | 'full'
+
+export type SearchProfile = {
+ id: string
+ key: string
+ shop: string
+ domain: string
+ locale: string
+ sales_channel_ids: string[]
+ strict: boolean
+ separate_variant_results: boolean
+ minimum_ranking_score: number | null
+ effective_minimum_ranking_score: number
+ availability: 'all' | 'in-stock'
+ autocomplete_product_limit: number
+ autocomplete_category_limit: number
+ autocomplete_brand_limit: number
+ autocomplete_content_limit: number
+ full_search_limit: number
+ max_results_per_page: number
+ popular_limit: number
+ last_sync_status: 'never' | 'running' | 'succeeded' | 'failed'
+ last_sync_mode: SearchSyncMode | null
+ last_sync_started_at: string | null
+ last_synced_at: string | null
+ last_sync_error: string | null
+ last_indexed_count: number
+ last_deleted_count: number
+ created_at: string
+ updated_at: string
+}
+
+export type SearchProfileInput = {
+ key: string
+ shop: string
+ domain: string
+ locale: string
+ sales_channel_ids: string[]
+ strict: boolean
+ separate_variant_results: boolean
+ minimum_ranking_score: number | null
+ availability: 'all' | 'in-stock'
+ autocomplete_product_limit: number
+ autocomplete_category_limit: number
+ autocomplete_brand_limit: number
+ autocomplete_content_limit: number
+ full_search_limit: number
+ max_results_per_page: number
+ popular_limit: number
+}
+
+export type SalesChannelOption = {
+ id: string
+ name: string
+}
+
+export type SearchTestResult = {
+ profile: string
+ type: SearchIndexType
+ query: string
+ minimum_ranking_score: number | null
+ hits: Record[]
+ raw_hit_count: number
+ processing_time_ms: number | null
+}
+
+export type SearchTestInput = {
+ query: string
+ type: SearchIndexType
+ limit: number
+}
+
+export type VectorStatus = {
+ enabled: boolean
+ provider?: string
+ model?: string
+ dimensions?: number
+ embeddingFields?: string[]
+}
+
+export type MeilisearchStatus = {
+ enabled: boolean
+ connected: boolean
+ status: string
+ error?: string
+}
+
+export const searchProfileQueryKeys = {
+ all: ['search-profiles'] as const,
+ list: () => ['search-profiles', 'list'] as const,
+ salesChannels: () => ['search-profiles', 'sales-channels'] as const,
+ vectorStatus: () => ['search-profiles', 'vector-status'] as const,
+ status: () => ['search-profiles', 'status'] as const
+}
+
+export const listSearchProfiles = async (): Promise => {
+ const response = await medusaClient.client.fetch<{ profiles: SearchProfile[] }>('/admin/search-profiles')
+
+ return response.profiles
+}
+
+export const listSalesChannels = async (): Promise => {
+ const response = await medusaClient.client.fetch<{ sales_channels: SalesChannelOption[] }>('/admin/sales-channels', { query: { fields: 'id,name', limit: 100 } })
+
+ return response.sales_channels
+}
+
+export const createSearchProfile = async (input: SearchProfileInput): Promise => {
+ const response = await medusaClient.client.fetch<{ profile: SearchProfile }>('/admin/search-profiles', { method: 'POST', body: input })
+
+ return response.profile
+}
+
+export const updateSearchProfile = async (id: string, input: SearchProfileInput): Promise => {
+ const response = await medusaClient.client.fetch<{ profile: SearchProfile }>('/admin/search-profiles/' + id, { method: 'POST', body: input })
+
+ return response.profile
+}
+
+export const deleteSearchProfile = async (id: string): Promise => {
+ await medusaClient.client.fetch('/admin/search-profiles/' + id, { method: 'DELETE' })
+}
+
+export const synchronizeSearchProfiles = async (mode: SearchSyncMode, id?: string) => medusaClient.client.fetch<{ result: { deleted: number; indexed: number; mode: SearchSyncMode; profiles: number } }>(id ? '/admin/search-profiles/' + id + '/sync' : '/admin/search-profiles/sync', { method: 'POST', body: { mode } })
+
+export const testSearchProfile = async (id: string, input: SearchTestInput): Promise => medusaClient.client.fetch('/admin/search-profiles/' + id + '/test', { method: 'POST', body: input })
+
+export const getVectorStatus = async (): Promise => medusaClient.client.fetch('/admin/meilisearch/vector-status')
+
+export const getMeilisearchStatus = async (): Promise => medusaClient.client.fetch('/admin/search-profiles/status')
+
+const DEFAULT_SEARCH_PROFILE_INPUT: SearchProfileInput = {
+ key: '',
+ shop: '',
+ domain: '',
+ locale: '',
+ sales_channel_ids: [],
+ strict: false,
+ separate_variant_results: false,
+ minimum_ranking_score: null,
+ availability: 'all',
+ autocomplete_product_limit: 6,
+ autocomplete_category_limit: 3,
+ autocomplete_brand_limit: 3,
+ autocomplete_content_limit: 3,
+ full_search_limit: 500,
+ max_results_per_page: 100,
+ popular_limit: 12
+}
+
+export const toSearchProfileInput = (profile?: SearchProfile): SearchProfileInput => {
+ if (!profile) {
+ return { ...DEFAULT_SEARCH_PROFILE_INPUT }
+ }
+
+ return {
+ key: profile.key,
+ shop: profile.shop,
+ domain: profile.domain,
+ locale: profile.locale,
+ sales_channel_ids: profile.sales_channel_ids,
+ strict: profile.strict,
+ separate_variant_results: profile.separate_variant_results,
+ minimum_ranking_score: profile.minimum_ranking_score,
+ availability: profile.availability,
+ autocomplete_product_limit: profile.autocomplete_product_limit,
+ autocomplete_category_limit: profile.autocomplete_category_limit,
+ autocomplete_brand_limit: profile.autocomplete_brand_limit,
+ autocomplete_content_limit: profile.autocomplete_content_limit,
+ full_search_limit: profile.full_search_limit,
+ max_results_per_page: profile.max_results_per_page,
+ popular_limit: profile.popular_limit
+ }
+}
diff --git a/apps/medusa-be/src/admin/routes/settings/meilisearch/components/search-profile-form.tsx b/apps/medusa-be/src/admin/routes/settings/meilisearch/components/search-profile-form.tsx
new file mode 100644
index 0000000000..53877ef42e
--- /dev/null
+++ b/apps/medusa-be/src/admin/routes/settings/meilisearch/components/search-profile-form.tsx
@@ -0,0 +1,553 @@
+import { Button, Checkbox, FocusModal, Heading, Input, Label, Select, Switch, Text } from '@medusajs/ui'
+import type { Dispatch, FormEvent, SetStateAction } from 'react'
+import { useEffect, useMemo, useState } from 'react'
+import { type SalesChannelOption, type SearchIndexType, type SearchProfile, type SearchProfileInput, toSearchProfileInput } from '../../../../lib/search-profiles'
+
+const INDEX_TYPES: SearchIndexType[] = ['product', 'category', 'brand', 'content']
+
+type AutocompleteLimitField = 'autocomplete_product_limit' | 'autocomplete_category_limit' | 'autocomplete_brand_limit' | 'autocomplete_content_limit'
+
+const AUTOCOMPLETE_LIMIT_FIELD_BY_TYPE: Record = {
+ product: 'autocomplete_product_limit',
+ category: 'autocomplete_category_limit',
+ brand: 'autocomplete_brand_limit',
+ content: 'autocomplete_content_limit'
+}
+
+const NumberField = ({ description, label, max, min = 1, onChange, value }: {
+ description: string
+ label: string
+ max: number
+ min?: number
+ onChange: (value: number) => void
+ value: number
+}) => {
+ const id = 'search-profile-' + label.toLowerCase().replaceAll(' ', '-')
+
+ return (
+
+
+ { label }
+
+
+ onChange(Number(event.target.value))
+ }
+ />
+
+
+ { description }
+
+
+ )
+}
+
+const ToggleField = ({ checked, description, label, onCheckedChange }: {
+ checked: boolean
+ description: string
+ label: string
+ onCheckedChange: (checked: boolean) => void
+}) => (
+
+
+
+ { label }
+
+
+
+ { description }
+
+
+
+
+
+)
+
+const normalizeProfileKeySegment = (value: string) => value.trim().toLowerCase().normalize('NFKD').replaceAll(/\p{Diacritic}/gu, '').replaceAll(/[^a-z0-9_-]+/g, '-').replaceAll(/-+/g, '-').replaceAll(/^-|-$/g, '')
+const generatedProfileKey = (form: SearchProfileInput) => [form.shop, form.domain, form.locale].map(normalizeProfileKeySegment).filter(Boolean).join('-')
+
+const SearchProfileFields = ({ form, salesChannels, setForm }: {
+ form: SearchProfileInput
+ salesChannels: SalesChannelOption[]
+ setForm: Dispatch>
+}) => {
+ const effectiveScore = form.minimum_ranking_score ?? (form.strict ? 0.98 : 0.55)
+
+ const toggleSalesChannel = (id: string, checked: boolean) => {
+ setForm((current) => ({
+ ...current,
+ sales_channel_ids: checked ? [...new Set([...current.sales_channel_ids, id])] : current.sales_channel_ids.filter((entry) => entry !== id)
+ }))
+ }
+
+ return (
+
+
+
+
+ Storefront scope
+
+
+
+ The stable Shop, Domain ID, and Language generate the profile key.
+
+
+
+
+
+
+ Profile key
+
+
+
+
+
+ {
+ (
+ [
+ ['shop', 'Shop', 'herbatika'],
+ ['domain', 'Domain ID', '1'],
+ ['locale', 'Language', 'sk']
+ ] as const
+ ).map(([field, label, placeholder]) => (
+
+
+ { label }
+
+
+ setForm((current) => ({
+ ...current,
+ [field]: event.target.value.toLowerCase()
+ }))
+ }
+ />
+
+ ))
+ }
+
+
+
+
+
+
+
+ Sales Channels
+
+
+
+ Assign the Sales Channels where this profile provides search. A profile without an assigned Sales Channel is disabled.
+
+
+
+
+ {
+ salesChannels.length ? (
+ salesChannels.map((channel) => (
+
+ toggleSalesChannel(channel.id, checked === true)
+ }
+ />
+
+
+
+ { channel.name }
+
+
+
+ { channel.id }
+
+
+
+ ))
+ ) : (
+
+ No Sales Channels were returned by Medusa.
+
+ )
+ }
+
+
+
+
+
+
+ Search behavior
+
+
+
+ These settings are applied by catalog search, autocomplete, and every index rebuild.
+
+
+
+
+ setForm((current) => {
+ const scoreFollowedPreviousDefault = current.minimum_ranking_score === (current.strict ? 0.98 : 0.55)
+
+ return { ...current, strict: strict, minimum_ranking_score: scoreFollowedPreviousDefault ? null : current.minimum_ranking_score }
+ })
+ }
+ />
+
+ setForm((current) => ({
+ ...current,
+ separate_variant_results: separateVariantResults
+ }))
+ }
+ />
+
+
+
+
+
+ Availability
+
+
+ setForm((current) => ({
+ ...current,
+ availability: availability as 'all' | 'in-stock'
+ }))
+ }
+ >
+
+
+
+
+
+
+ All published and orderable products
+
+
+
+ Only products in stock
+
+
+
+
+
+
+
+ Minimum ranking score
+
+
+ setForm((current) => ({
+ ...current,
+ minimum_ranking_score: event.target.value === '' ? null : Number(event.target.value)
+ }))
+ }
+ />
+
+
+ Effective score: { effectiveScore }. Leave empty to follow the strict/loose default.
+
+
+
+
+
+
+
+
+ Result limits
+
+
+
+ Keep browser payloads and authoritative Medusa hydration bounded on large shops.
+
+
+
+
+ setForm((current) => ({
+ ...current,
+ max_results_per_page: value
+ }))
+ }
+ />
+
+ setForm((current) => ({
+ ...current,
+ full_search_limit: value
+ }))
+ }
+ />
+
+ setForm((current) => ({
+ ...current,
+ popular_limit: value
+ }))
+ }
+ />
+
+
+
+ {
+ INDEX_TYPES.map((type) => {
+ const field = AUTOCOMPLETE_LIMIT_FIELD_BY_TYPE[type]
+
+ return (
+ setForm((current) => ({
+ ...current,
+ [field]: value
+ }))
+ }
+ />
+ )
+ })
+ }
+
+
+
+
+ )
+}
+
+export const SearchProfileFormModal = ({ onOpenChange, onSubmit, open, profile, salesChannels, submitting }: {
+ onOpenChange: (open: boolean) => void
+ onSubmit: (input: SearchProfileInput) => void
+ open: boolean
+ profile?: SearchProfile
+ salesChannels: SalesChannelOption[]
+ submitting: boolean
+}) => {
+ const [form, setForm] = useState(() => toSearchProfileInput(profile))
+
+ useEffect(() => {
+ if (open) {
+ setForm(toSearchProfileInput(profile))
+ }
+ }, [open, profile])
+
+ const valid = useMemo(
+ () =>
+ [generatedProfileKey(form), form.shop, form.domain, form.locale].every((value) => value.trim().length > 0) &&
+ [
+ form.autocomplete_product_limit,
+ form.autocomplete_category_limit,
+ form.autocomplete_brand_limit,
+ form.autocomplete_content_limit,
+ form.full_search_limit,
+ form.max_results_per_page,
+ form.popular_limit
+ ].every((value) => Number.isInteger(value) && value > 0) &&
+ (form.minimum_ranking_score === null || (form.minimum_ranking_score >= 0 && form.minimum_ranking_score <= 1)),
+
+ [form])
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault()
+
+ const submittedForm = {
+ ...form,
+ key: generatedProfileKey(form),
+ shop: form.shop.trim(),
+ domain: form.domain.trim(),
+ locale: form.locale.trim()
+ }
+
+ onSubmit(submittedForm)
+ }
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/apps/medusa-be/src/admin/routes/settings/meilisearch/components/search-test-panel.tsx b/apps/medusa-be/src/admin/routes/settings/meilisearch/components/search-test-panel.tsx
new file mode 100644
index 0000000000..eb868d0e94
--- /dev/null
+++ b/apps/medusa-be/src/admin/routes/settings/meilisearch/components/search-test-panel.tsx
@@ -0,0 +1,277 @@
+import { Button, Heading, Input, Label, Select, Table, Text, toast } from '@medusajs/ui'
+import { useMutation } from '@tanstack/react-query'
+import { useEffect, useState } from 'react'
+import { type SearchIndexType, type SearchProfile, type SearchTestResult, testSearchProfile } from '../../../../lib/search-profiles'
+
+const INDEX_TYPES: SearchIndexType[] = ['product', 'category', 'brand', 'content']
+
+const hitLabel = (hit: Record): string => {
+ for (const field of ['title', 'name', 'handle', 'href', 'id']) {
+ const value = hit[field]
+
+ if (typeof value === 'string' && value) {
+ return value
+ }
+ }
+
+ return 'Untitled result'
+}
+
+const hitId = (hit: Record, index: number): string => {
+ const value = hit.id
+
+ return typeof value === 'string' || typeof value === 'number' ? String(value) : 'result-' + index
+}
+
+export const SearchTestPanel = ({ profiles }: { profiles: SearchProfile[] }) => {
+ const assignedProfiles = profiles.filter((profile) => profile.sales_channel_ids.length > 0)
+ const defaultProfile = assignedProfiles[0]
+ const [profileId, setProfileId] = useState(defaultProfile?.id ?? '')
+ const [type, setType] = useState('product')
+ const [query, setQuery] = useState('')
+ const [result, setResult] = useState()
+
+ useEffect(() => {
+ if (!profiles.some((profile) => profile.id === profileId)) {
+ setProfileId(defaultProfile?.id ?? '')
+ }
+ }, [defaultProfile?.id, profileId, profiles])
+
+ const mutationOptions = {
+ mutationFn: () => testSearchProfile(profileId, { query: query, type: type, limit: 10 }),
+
+ onError: (error: unknown) => {
+ toast.error(error instanceof Error ? error.message : 'Search testing failed.')
+ },
+
+ onSuccess: (data: SearchTestResult) => {
+ setResult(data)
+ toast.success('Search returned ' + data.hits.length + ' accepted result(s).')
+ }
+ }
+
+ const mutation = useMutation(mutationOptions)
+
+ return (
+
+
+
+ Search testing
+
+
+
+ Query the exact index and relevance policy selected for a storefront profile. An empty product query tests popular-product ordering.
+
+
+
+
+
+
+ Profile
+
+
+
+
+
+
+
+
+ {
+ assignedProfiles.map((profile) => (
+
+ { profile.key }
+
+ ))
+ }
+
+
+
+
+
+
+ Index type
+
+
+ setType(value as SearchIndexType)
+ }
+ >
+
+
+
+
+
+ {
+ INDEX_TYPES.map((indexType) => (
+
+ { indexType[0]?.toUpperCase() }
+ { indexType.slice(1) }
+
+ ))
+ }
+
+
+
+
+
+
+ Query
+
+
+ setQuery(event.target.value)
+ }
+
+ onKeyDown = {
+ (event) => {
+ if (event.key === 'Enter' && profileId) {
+ mutation.mutate()
+ }
+ }
+ }
+ />
+
+
+
mutation.mutate()
+ }
+ >
+ Test search
+
+
+
+ {
+ result && (
+
+
+
+ { result.hits.length } accepted of { result.raw_hit_count } raw hits
+
+
+
+ { result.processing_time_ms !== null ? result.processing_time_ms + ' ms' : '' }
+ { result.minimum_ranking_score !== null ? ' · min score ' + result.minimum_ranking_score : '' }
+
+
+
+
+
+
+
+ Result
+
+
+
+ ID
+
+
+
+ Ranking score
+
+
+
+ Document
+
+
+
+
+
+ {
+ result.hits.length ? (
+ result.hits.map((hit, index) => (
+
+
+ { hitLabel(hit) }
+
+
+
+ { hitId(hit, index) }
+
+
+
+ { typeof hit._rankingScore === 'number' ? hit._rankingScore.toFixed(4) : '—' }
+
+
+
+
+ Inspect
+
+
+ { JSON.stringify(hit, null, 2) }
+
+
+
+
+ ))
+ ) : (
+
+
+ No accepted results.
+
+
+
+
+
+
+ )
+ }
+
+
+
+ )
+ }
+
+ )
+}
diff --git a/apps/medusa-be/src/admin/routes/settings/meilisearch/page.tsx b/apps/medusa-be/src/admin/routes/settings/meilisearch/page.tsx
new file mode 100644
index 0000000000..9acd88f65d
--- /dev/null
+++ b/apps/medusa-be/src/admin/routes/settings/meilisearch/page.tsx
@@ -0,0 +1,505 @@
+import { defineRouteConfig } from '@medusajs/admin-sdk'
+import { MagnifyingGlass, PencilSquare, Plus, Trash } from '@medusajs/icons'
+import { Badge, Button, Container, Heading, IconButton, StatusBadge, Table, Text, toast, usePrompt } from '@medusajs/ui'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useState } from 'react'
+import { createSearchProfile, deleteSearchProfile, getMeilisearchStatus, listSalesChannels, listSearchProfiles, type SearchProfile, type SearchProfileInput, type SearchSyncMode, searchProfileQueryKeys, synchronizeSearchProfiles, updateSearchProfile } from '../../../lib/search-profiles'
+import { SearchProfileFormModal } from './components/search-profile-form'
+import { SearchTestPanel } from './components/search-test-panel'
+
+export const handle = {
+ breadcrumb: () => 'Meilisearch'
+}
+
+const formatDate = (value: string | null): string => {
+ if (!value) {
+ return 'Never'
+ }
+
+ return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
+}
+
+const syncStatusColor = (status: SearchProfile['last_sync_status']): 'green' | 'red' | 'orange' | 'grey' => {
+ if (status === 'succeeded') {
+ return 'green'
+ }
+
+ if (status === 'failed') {
+ return 'red'
+ }
+
+ if (status === 'running') {
+ return 'orange'
+ }
+
+ return 'grey'
+}
+
+const ProfileBadges = ({ profile }: { profile: SearchProfile }) => (
+
+
+ { profile.strict ? 'Strict' : 'Loose' }
+
+
+
+ { profile.separate_variant_results ? 'Separate variants' : 'Grouped variants' }
+
+
+)
+
+const getMeilisearchStatusBadge = (options: {
+ connected?: boolean
+ enabled?: boolean
+ loading: boolean
+}): {
+ color: 'green' | 'red' | 'grey'
+ label: string
+} => {
+ if (options.loading) {
+ return { color: 'grey', label: 'Checking' }
+ }
+
+ if (options.connected) {
+ return { color: 'green', label: 'Connected' }
+ }
+
+ if (options.enabled) {
+ return { color: 'red', label: 'Unavailable' }
+ }
+
+ return { color: 'grey', label: 'Disabled' }
+}
+
+const MeilisearchSettingsPage = () => {
+ const queryClient = useQueryClient()
+ const prompt = usePrompt()
+ const [formOpen, setFormOpen] = useState(false)
+ const [editingProfile, setEditingProfile] = useState()
+ const [syncTarget, setSyncTarget] = useState()
+ const profilesQuery = useQuery({ queryFn: listSearchProfiles, queryKey: searchProfileQueryKeys.list() })
+ const salesChannelsQuery = useQuery({ queryFn: listSalesChannels, queryKey: searchProfileQueryKeys.salesChannels() })
+ const statusQuery = useQuery({ queryFn: getMeilisearchStatus, queryKey: searchProfileQueryKeys.status(), retry: 1 })
+
+ const refreshProfiles = async () => {
+ await queryClient.invalidateQueries({ queryKey: searchProfileQueryKeys.all })
+ }
+
+ const saveMutationOptions = {
+ mutationFn: (input: SearchProfileInput) => (editingProfile ? updateSearchProfile(editingProfile.id, input) : createSearchProfile(input)),
+
+ onError: (error: unknown) => {
+ toast.error(error instanceof Error ? error.message : 'Unable to save profile.')
+ },
+
+ onSuccess: async () => {
+ await refreshProfiles()
+
+ toast.success(editingProfile ? 'Search profile updated.' : 'Search profile created.')
+
+ setFormOpen(false)
+ setEditingProfile(undefined)
+ }
+ }
+
+ const saveMutation = useMutation(saveMutationOptions)
+
+ const deleteMutationOptions = {
+ mutationFn: deleteSearchProfile,
+
+ onError: (error: unknown) => {
+ toast.error(error instanceof Error ? error.message : 'Unable to delete profile.')
+ },
+
+ onSuccess: async () => {
+ await refreshProfiles()
+
+ toast.success('Search profile deleted. Existing indexes were preserved.')
+ }
+ }
+
+ const deleteMutation = useMutation(deleteMutationOptions)
+
+ const syncMutationOptions = {
+ mutationFn: ({ id, mode }: { id?: string; mode: SearchSyncMode }) => synchronizeSearchProfiles(mode, id),
+
+ onError: (error: unknown) => {
+ toast.error(error instanceof Error ? error.message : 'Synchronization failed.')
+ },
+
+ onSettled: async () => {
+ setSyncTarget(undefined)
+
+ await refreshProfiles()
+ },
+
+ onSuccess: ({ result }: Awaited>) => {
+ toast.success((result.mode === 'full' ? 'Full rebuild' : 'Normal synchronization') + ' completed: ' + result.indexed + ' indexed, ' + result.deleted + ' deleted.')
+ }
+ }
+
+ const syncMutation = useMutation(syncMutationOptions)
+ const profiles = profilesQuery.data ?? []
+ const assignedProfiles = profiles.filter((profile) => profile.sales_channel_ids.length > 0)
+ const salesChannels = salesChannelsQuery.data ?? []
+ const salesChannelNames = new Map(salesChannels.map((channel) => [channel.id, channel.name]))
+ const meilisearchStatusBadge = getMeilisearchStatusBadge({ connected: statusQuery.data?.connected, enabled: statusQuery.data?.enabled, loading: statusQuery.isLoading })
+
+ const startSync = (mode: SearchSyncMode, id?: string) => {
+ setSyncTarget(id ?? 'all')
+
+ syncMutation.mutate({ id, mode })
+ }
+
+ const requestDelete = async (profile: SearchProfile) => {
+ const confirmed = await prompt({ title: 'Delete ' + profile.key + '?', description: 'The profile will stop resolving and synchronizing. Existing Meilisearch indexes are deliberately preserved for rollback.', confirmText: 'Delete', cancelText: 'Cancel' })
+
+ if (confirmed) {
+ deleteMutation.mutate(profile.id)
+ }
+ }
+
+ const openCreate = () => {
+ setEditingProfile(undefined)
+ setFormOpen(true)
+ }
+
+ const openEdit = (profile: SearchProfile) => {
+ setEditingProfile(profile)
+ setFormOpen(true)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ Meilisearch Configuration
+
+
+
+
+ Domain-scoped search profiles are stored in Medusa and applied to storefront search, autocomplete, and synchronization.
+
+
+
+
+
+ { meilisearchStatusBadge.label }
+
+ {
+ statusQuery.data?.error && (
+
+ { statusQuery.data.error }
+
+ )
+ }
+
+
startSync('normal')
+ }
+ >
+ Sync all
+
+
+
startSync('full')
+ }
+ >
+ Full rebuild all
+
+
+
+
+ Add profile
+
+
+
+
+
+
+
+
+ Search profiles
+
+
+
+ One profile represents a unique Shop + Domain ID + Language combination and maps it to Medusa Sales Channels.
+
+
+
+
+
+
+
+
+ Profile
+
+
+
+ Sales Channels
+
+
+
+ Behavior
+
+
+
+ Last synchronization
+
+
+
+ Actions
+
+
+
+
+
+ {
+ profilesQuery.isLoading && (
+
+
+ Loading profiles…
+
+
+
+
+
+
+
+ )
+ }
+ {
+ profilesQuery.error && (
+
+
+ { profilesQuery.error.message }
+
+
+
+
+
+
+
+ )
+ }
+ {
+ !profilesQuery.isLoading && !profilesQuery.error && !profiles.length && (
+
+
+ No search profiles configured.
+
+
+
+
+
+
+
+ )
+ }
+ {
+ profiles.map((profile) => (
+
+
+
+
+ { profile.key }
+
+
+
+
+
+
+ {
+ profile.sales_channel_ids.length ? profile.sales_channel_ids.map((id) => (
+
+ { salesChannelNames.get(id) ?? id }
+
+ )) : 'Not assigned'
+ }
+
+
+
+
+
+
+
+
+
+
+ { profile.last_sync_status }
+
+
+
+ { formatDate(profile.last_synced_at) }
+
+ {
+ profile.last_sync_error && (
+
+ { profile.last_sync_error }
+
+ )
+ }
+
+
+
+
+
+
startSync('normal', profile.id)
+ }
+ >
+ Sync
+
+
+
startSync('full', profile.id)
+ }
+ >
+ Full
+
+
+
openEdit(profile)
+ }
+ >
+
+
+
+
requestDelete(profile)
+ }
+ >
+
+
+
+
+
+ ))
+ }
+
+
+
+
+
+
+
+
+
+
{
+ setFormOpen(open)
+
+ if (!open) {
+ setEditingProfile(undefined)
+ }
+ }
+ }
+
+ onSubmit = {
+ (input) => saveMutation.mutate(input)
+ }
+ />
+
+ )
+}
+
+export const config = defineRouteConfig({ label: 'Meilisearch' })
+
+export default MeilisearchSettingsPage
diff --git a/apps/medusa-be/src/api/admin-app-static.ts b/apps/medusa-be/src/api/admin-app-static.ts
index 45a2a9a307..fcd7713ee5 100644
--- a/apps/medusa-be/src/api/admin-app-static.ts
+++ b/apps/medusa-be/src/api/admin-app-static.ts
@@ -6,10 +6,15 @@ import type {
MedusaResponse,
} from "@medusajs/framework"
-const ADMIN_PUBLIC_DIRS = [
- path.join(process.cwd(), ".medusa/server/public/app"),
- path.join(process.cwd(), ".medusa/server/public/admin"),
+const MEDUSA_PROJECT_DIRS = [
+ process.cwd(),
+ path.join(process.cwd(), "apps/medusa-be"),
]
+const ADMIN_PUBLIC_DIRS = MEDUSA_PROJECT_DIRS.flatMap((projectDir) => [
+ path.join(projectDir, ".medusa/server/public/app"),
+ path.join(projectDir, ".medusa/server/public/admin"),
+ path.join(projectDir, ".medusa/admin"),
+])
const APP_PATH_PREFIX_REGEX = /^\/app\/?/
const getAdminPublicDir = () =>
@@ -26,6 +31,13 @@ const isPathInsideDirectory = (baseDir: string, filePath: string) => {
}
const getRequestPath = (req: MedusaRequest) => {
+ if (
+ "originalUrl" in req &&
+ typeof req.originalUrl === "string"
+ ) {
+ return req.originalUrl.split("?")[0] ?? "/app"
+ }
+
if ("path" in req && typeof req.path === "string") {
return req.path
}
diff --git a/apps/medusa-be/src/api/admin/search-profiles/[id]/route.ts b/apps/medusa-be/src/api/admin/search-profiles/[id]/route.ts
new file mode 100644
index 0000000000..5b38fbcafe
--- /dev/null
+++ b/apps/medusa-be/src/api/admin/search-profiles/[id]/route.ts
@@ -0,0 +1,43 @@
+import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http'
+import type { Query } from '@medusajs/framework/types'
+import { ContainerRegistrationKeys } from '@medusajs/framework/utils'
+import { getSearchProfileService, retrieveSearchProfileOrThrow, toSearchProfileResponse, toSearchProfileWriteInput, updateStoredSearchProfile, validateProfileChange, validateSalesChannelIds } from '../utils'
+import type { AdminSearchProfileInputSchemaType } from '../validators'
+
+export async function GET(request: MedusaRequest, response: MedusaResponse) {
+ const service = getSearchProfileService(request.scope)
+ const profile = await retrieveSearchProfileOrThrow(service, request.params.id ?? '')
+
+ response.json({ profile: toSearchProfileResponse(profile) })
+}
+
+export async function POST(request: MedusaRequest, response: MedusaResponse) {
+ const service = getSearchProfileService(request.scope)
+ const id = request.params.id ?? ''
+
+ await retrieveSearchProfileOrThrow(service, id)
+
+ const query = request.scope.resolve(ContainerRegistrationKeys.QUERY)
+ const input = toSearchProfileWriteInput(request.validatedBody)
+
+ await validateSalesChannelIds(query, input.sales_channel_ids)
+ await validateProfileChange({ currentId: id, input: input, service: service })
+
+ const updated = await updateStoredSearchProfile(service, id, input)
+
+ await service.invalidateRuntimeProfileCache()
+
+ response.json({ profile: toSearchProfileResponse(updated) })
+}
+
+export async function DELETE(request: MedusaRequest, response: MedusaResponse) {
+ const service = getSearchProfileService(request.scope)
+ const id = request.params.id ?? ''
+
+ await retrieveSearchProfileOrThrow(service, id)
+
+ await service.deleteSearchProfiles(id)
+ await service.invalidateRuntimeProfileCache()
+
+ response.json({ deleted: true, id: id, object: 'search_profile' })
+}
diff --git a/apps/medusa-be/src/api/admin/search-profiles/[id]/sync/route.ts b/apps/medusa-be/src/api/admin/search-profiles/[id]/sync/route.ts
new file mode 100644
index 0000000000..54b786a17a
--- /dev/null
+++ b/apps/medusa-be/src/api/admin/search-profiles/[id]/sync/route.ts
@@ -0,0 +1,18 @@
+import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http'
+import { MedusaError } from '@medusajs/framework/utils'
+import { synchronizeSearchProfiles } from '../../../../../modules/meilisearch/synchronize'
+import { getSearchProfileService, retrieveSearchProfileOrThrow } from '../../utils'
+import type { AdminSearchProfileSyncSchemaType } from '../../validators'
+
+export async function POST(request: MedusaRequest, response: MedusaResponse) {
+ const service = getSearchProfileService(request.scope)
+ const profile = await retrieveSearchProfileOrThrow(service, request.params.id ?? '')
+
+ if (!Array.isArray(profile.sales_channel_ids) || profile.sales_channel_ids.length === 0) {
+ throw new MedusaError(MedusaError.Types.NOT_ALLOWED, 'Assign at least one Sales Channel before synchronizing this profile.')
+ }
+
+ const result = await synchronizeSearchProfiles(request.scope, request.validatedBody.mode, { profileKeys: [profile.key] })
+
+ response.json({ result })
+}
diff --git a/apps/medusa-be/src/api/admin/search-profiles/[id]/test/route.ts b/apps/medusa-be/src/api/admin/search-profiles/[id]/test/route.ts
new file mode 100644
index 0000000000..3e0f39f80c
--- /dev/null
+++ b/apps/medusa-be/src/api/admin/search-profiles/[id]/test/route.ts
@@ -0,0 +1,30 @@
+import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http'
+import type { MeiliSearchService } from '@rokmohar/medusa-plugin-meilisearch'
+import { cleanSearchText } from '../../../../../modules/meilisearch/documents'
+import { persistedSearchProfileToRuntime } from '../../../../../modules/meilisearch/profiles'
+import { buildProductResultFilter, isAcceptedProductHit } from '../../../../../modules/meilisearch/search-results'
+import { MEILISEARCH } from '../../../../../workflows/meilisearch'
+import { getSearchProfileService, retrieveSearchProfileOrThrow } from '../../utils'
+import type { AdminSearchProfileTestSchemaType } from '../../validators'
+
+export async function POST(request: MedusaRequest, response: MedusaResponse) {
+ const service = getSearchProfileService(request.scope)
+ const persisted = await retrieveSearchProfileOrThrow(service, request.params.id ?? '')
+ const profile = persistedSearchProfileToRuntime(persisted)
+ const { limit, type } = request.validatedBody
+ const query = cleanSearchText(request.validatedBody.query)
+ const meilisearch = request.scope.resolve(MEILISEARCH)
+ const result = await meilisearch.search(profile.indexes[type], query, { paginationOptions: { limit, offset: 0 }, filter: type === 'product' ? buildProductResultFilter(profile.separateVariantResults, query) : undefined, additionalOptions: { showRankingScore: true, ...(type === 'product' && !query ? { sort: ['facet_popularity:desc'] } : {}) } })
+ const hits = Array.isArray(result.hits) ? result.hits : []
+ const acceptedHits = type === 'product' && query ? hits.filter((hit) => isAcceptedProductHit(hit, query, profile.minimumRankingScore, profile.strict)) : hits
+
+ response.json({
+ profile: profile.key,
+ type: type,
+ query: query,
+ minimum_ranking_score: type === 'product' ? profile.minimumRankingScore : null,
+ hits: acceptedHits,
+ raw_hit_count: hits.length,
+ processing_time_ms: (result as unknown as { processingTimeMs?: number }).processingTimeMs ?? null
+ })
+}
diff --git a/apps/medusa-be/src/api/admin/search-profiles/middlewares.ts b/apps/medusa-be/src/api/admin/search-profiles/middlewares.ts
new file mode 100644
index 0000000000..5fe44a253c
--- /dev/null
+++ b/apps/medusa-be/src/api/admin/search-profiles/middlewares.ts
@@ -0,0 +1,31 @@
+import { validateAndTransformBody } from '@medusajs/framework'
+import type { MiddlewareRoute } from '@medusajs/framework/http'
+import { AdminSearchProfileInputSchema, AdminSearchProfileSyncSchema, AdminSearchProfileTestSchema } from './validators'
+
+export const adminSearchProfileRoutesMiddlewares: MiddlewareRoute[] = [
+ {
+ methods: ['POST'],
+ matcher: '/admin/search-profiles',
+ middlewares: [validateAndTransformBody(AdminSearchProfileInputSchema)]
+ },
+ {
+ methods: ['POST'],
+ matcher: '/admin/search-profiles/sync',
+ middlewares: [validateAndTransformBody(AdminSearchProfileSyncSchema)]
+ },
+ {
+ methods: ['POST'],
+ matcher: '/admin/search-profiles/:id',
+ middlewares: [validateAndTransformBody(AdminSearchProfileInputSchema)]
+ },
+ {
+ methods: ['POST'],
+ matcher: '/admin/search-profiles/:id/sync',
+ middlewares: [validateAndTransformBody(AdminSearchProfileSyncSchema)]
+ },
+ {
+ methods: ['POST'],
+ matcher: '/admin/search-profiles/:id/test',
+ middlewares: [validateAndTransformBody(AdminSearchProfileTestSchema)]
+ }
+]
diff --git a/apps/medusa-be/src/api/admin/search-profiles/route.ts b/apps/medusa-be/src/api/admin/search-profiles/route.ts
new file mode 100644
index 0000000000..fc55fc76c8
--- /dev/null
+++ b/apps/medusa-be/src/api/admin/search-profiles/route.ts
@@ -0,0 +1,28 @@
+import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http'
+import type { Query } from '@medusajs/framework/types'
+import { ContainerRegistrationKeys } from '@medusajs/framework/utils'
+import type { SearchProfileDTO } from '../../../modules/search-profile'
+import { getSearchProfileService, toSearchProfileResponse, toSearchProfileWriteInput, validateProfileChange, validateSalesChannelIds } from './utils'
+import type { AdminSearchProfileInputSchemaType } from './validators'
+
+export async function GET(request: MedusaRequest, response: MedusaResponse) {
+ const service = getSearchProfileService(request.scope)
+ const profiles = await service.listConfiguredProfiles()
+
+ response.json({ profiles: profiles.map(toSearchProfileResponse) })
+}
+
+export async function POST(request: MedusaRequest, response: MedusaResponse) {
+ const service = getSearchProfileService(request.scope)
+ const query = request.scope.resolve(ContainerRegistrationKeys.QUERY)
+ const input = toSearchProfileWriteInput(request.validatedBody)
+
+ await validateSalesChannelIds(query, input.sales_channel_ids)
+ await validateProfileChange({ input, service })
+
+ const created = (await service.createSearchProfiles(input)) as unknown as SearchProfileDTO
+
+ await service.invalidateRuntimeProfileCache()
+
+ response.status(201).json({ profile: toSearchProfileResponse(created) })
+}
diff --git a/apps/medusa-be/src/api/admin/search-profiles/status/route.ts b/apps/medusa-be/src/api/admin/search-profiles/status/route.ts
new file mode 100644
index 0000000000..000ec0fc4c
--- /dev/null
+++ b/apps/medusa-be/src/api/admin/search-profiles/status/route.ts
@@ -0,0 +1,19 @@
+import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http'
+import { MeilisearchAdminClient } from '../../../../modules/meilisearch/admin-client'
+import { isMeilisearchEnabled } from '../../../../modules/meilisearch/env'
+
+export async function GET(_request: MedusaRequest, response: MedusaResponse) {
+ if (!isMeilisearchEnabled()) {
+ response.json({ enabled: false, connected: false, status: 'disabled' })
+
+ return
+ }
+
+ try {
+ const health = await new MeilisearchAdminClient().health()
+
+ response.json({ enabled: true, connected: health.status === 'available', status: health.status ?? 'unknown' })
+ } catch (error) {
+ response.json({ enabled: true, connected: false, status: 'unavailable', error: error instanceof Error ? error.message : String(error) })
+ }
+}
diff --git a/apps/medusa-be/src/api/admin/search-profiles/sync/route.ts b/apps/medusa-be/src/api/admin/search-profiles/sync/route.ts
new file mode 100644
index 0000000000..45ee3019c7
--- /dev/null
+++ b/apps/medusa-be/src/api/admin/search-profiles/sync/route.ts
@@ -0,0 +1,9 @@
+import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http'
+import { synchronizeSearchProfiles } from '../../../../modules/meilisearch/synchronize'
+import type { AdminSearchProfileSyncSchemaType } from '../validators'
+
+export async function POST(request: MedusaRequest, response: MedusaResponse) {
+ const result = await synchronizeSearchProfiles(request.scope, request.validatedBody.mode)
+
+ response.json({ result })
+}
diff --git a/apps/medusa-be/src/api/admin/search-profiles/utils.ts b/apps/medusa-be/src/api/admin/search-profiles/utils.ts
new file mode 100644
index 0000000000..53cf71a304
--- /dev/null
+++ b/apps/medusa-be/src/api/admin/search-profiles/utils.ts
@@ -0,0 +1,117 @@
+import type { MedusaContainer, Query } from '@medusajs/framework/types'
+import { MedusaError } from '@medusajs/framework/utils'
+import { parseSearchProfileConfiguration, persistedSearchProfileToRuntime, type SearchProfile, SearchProfileConfigurationError, validateSearchProfileSet } from '../../../modules/meilisearch/profiles'
+import { SEARCH_PROFILE_MODULE, type SearchProfileDTO, type SearchProfileModuleService, type SearchProfileWriteInput } from '../../../modules/search-profile'
+import type { AdminSearchProfileInputSchemaType } from './validators'
+
+type MutableSearchProfileService = SearchProfileModuleService & {
+ createSearchProfiles: (data: Record) => Promise
+ deleteSearchProfiles: (ids: string | string[]) => Promise
+ retrieveSearchProfile: (id: string) => Promise
+ updateSearchProfiles: (data: Record | Record[]) => Promise
+ invalidateRuntimeProfileCache: () => Promise
+}
+
+export const getSearchProfileService = (container: MedusaContainer): MutableSearchProfileService => container.resolve(SEARCH_PROFILE_MODULE)
+
+export const toSearchProfileWriteInput = (input: AdminSearchProfileInputSchemaType): SearchProfileWriteInput => ({
+ ...input,
+ sales_channel_ids: [...new Set(input.sales_channel_ids)]
+})
+
+const writeInputToRuntime = (input: SearchProfileWriteInput, id?: string): SearchProfile => ({
+ ...parseSearchProfileConfiguration({
+ availability: input.availability,
+ domain: input.domain,
+ key: input.key,
+
+ limits: {
+ autocomplete: {
+ product: input.autocomplete_product_limit,
+ category: input.autocomplete_category_limit,
+ brand: input.autocomplete_brand_limit,
+ content: input.autocomplete_content_limit
+ },
+
+ fullSearch: input.full_search_limit,
+ page: input.max_results_per_page,
+ popular: input.popular_limit
+ },
+
+ locale: input.locale,
+ minimumRankingScore: input.minimum_ranking_score ?? undefined,
+ salesChannelIds: input.sales_channel_ids,
+ separateVariantResults: input.separate_variant_results,
+ shop: input.shop,
+ strict: input.strict
+ }),
+
+ id
+})
+
+export const validateSalesChannelIds = async (query: Query, ids: string[]) => {
+ const uniqueIds = [...new Set(ids)]
+
+ if (!uniqueIds.length) {
+ return
+ }
+
+ const { data } = await query.graph({ entity: 'sales_channel', fields: ['id'], filters: { id: { $in: uniqueIds } } })
+ const found = new Set((data as { id?: string }[]).map((entry) => entry.id).filter((id): id is string => Boolean(id)))
+ const missing = uniqueIds.filter((id) => !found.has(id))
+
+ if (missing.length) {
+ throw new MedusaError(MedusaError.Types.INVALID_DATA, 'Unknown Sales Channel IDs: ' + missing.join(', '))
+ }
+}
+
+export const validateProfileChange = async (options: { currentId?: string; input: SearchProfileWriteInput; service: SearchProfileModuleService }) => {
+ const records = await options.service.listConfiguredProfiles()
+
+ const candidates = records
+ .filter((profile) => profile.id !== options.currentId)
+ .map((profile) => persistedSearchProfileToRuntime(profile))
+
+ const candidate = writeInputToRuntime(options.input, options.currentId)
+
+ const enabledProfiles = [
+ ...candidates.filter((profile) => profile.salesChannelIds.length > 0),
+ ...(options.input.sales_channel_ids.length > 0 ? [candidate] : [])
+ ]
+
+ try {
+ validateSearchProfileSet(enabledProfiles)
+ } catch (error) {
+ if (error instanceof SearchProfileConfigurationError) {
+ throw new MedusaError(MedusaError.Types.INVALID_DATA, error.message)
+ }
+
+ throw error
+ }
+}
+
+export const toSearchProfileResponse = (profile: SearchProfileDTO) => {
+ const runtime = persistedSearchProfileToRuntime(profile)
+ const salesChannelIds = Array.isArray(profile.sales_channel_ids) ? profile.sales_channel_ids : []
+
+ return { ...profile, sales_channel_ids: salesChannelIds, effective_minimum_ranking_score: runtime.minimumRankingScore }
+}
+
+export const retrieveSearchProfileOrThrow = async (service: SearchProfileModuleService, id: string): Promise => {
+ try {
+ return (await service.retrieveSearchProfile(id)) as unknown as SearchProfileDTO
+ } catch {
+ throw new MedusaError(MedusaError.Types.NOT_FOUND, 'Search profile ' + id + ' was not found.')
+ }
+}
+
+export const updateStoredSearchProfile = async (service: MutableSearchProfileService, id: string, input: SearchProfileWriteInput) => {
+ const result = await service.updateSearchProfiles({ id: id, ...input })
+ const updated = Array.isArray(result) ? result.find((profile) => profile.id === id) : result
+
+ if (!updated) {
+ throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, 'Search profile update returned no result.')
+ }
+
+ return updated
+}
diff --git a/apps/medusa-be/src/api/admin/search-profiles/validators.ts b/apps/medusa-be/src/api/admin/search-profiles/validators.ts
new file mode 100644
index 0000000000..78bcc9a6da
--- /dev/null
+++ b/apps/medusa-be/src/api/admin/search-profiles/validators.ts
@@ -0,0 +1,43 @@
+import { z as zod } from '@medusajs/framework/zod'
+
+const INDEX_SAFE_VALUE = /^[a-z0-9][a-z0-9_-]*$/
+const indexSafeString = zod.string().trim().min(1).max(255).regex(INDEX_SAFE_VALUE, 'Use lowercase letters, numbers, underscores, and hyphens only.')
+
+export const AdminSearchProfileInputSchema = zod
+ .object({
+ key: indexSafeString,
+ shop: indexSafeString,
+ domain: indexSafeString,
+ locale: indexSafeString,
+ sales_channel_ids: zod.array(zod.string().trim().min(1)).max(50),
+ strict: zod.boolean(),
+ separate_variant_results: zod.boolean(),
+ minimum_ranking_score: zod.number().finite().min(0).max(1).nullable(),
+ availability: zod.enum(['all', 'in-stock']),
+ autocomplete_product_limit: zod.number().int().min(1).max(24),
+ autocomplete_category_limit: zod.number().int().min(1).max(24),
+ autocomplete_brand_limit: zod.number().int().min(1).max(24),
+ autocomplete_content_limit: zod.number().int().min(1).max(24),
+ full_search_limit: zod.number().int().min(1).max(1000),
+ max_results_per_page: zod.number().int().min(1).max(100),
+ popular_limit: zod.number().int().min(1).max(48)
+ })
+ .strict()
+
+export const AdminSearchProfileSyncSchema = zod
+ .object({
+ mode: zod.enum(['normal', 'full'])
+ })
+ .strict()
+
+export const AdminSearchProfileTestSchema = zod
+ .object({
+ query: zod.string().trim().max(250),
+ type: zod.enum(['product', 'category', 'brand', 'content']),
+ limit: zod.number().int().min(1).max(25).optional().default(10)
+ })
+ .strict()
+
+export type AdminSearchProfileInputSchemaType = zod.infer
+export type AdminSearchProfileSyncSchemaType = zod.infer
+export type AdminSearchProfileTestSchemaType = zod.infer
diff --git a/apps/medusa-be/src/api/hooks/cms/invalidate/route.ts b/apps/medusa-be/src/api/hooks/cms/invalidate/route.ts
index ef9968f9ec..7784195073 100644
--- a/apps/medusa-be/src/api/hooks/cms/invalidate/route.ts
+++ b/apps/medusa-be/src/api/hooks/cms/invalidate/route.ts
@@ -2,6 +2,10 @@ import { createHmac } from "node:crypto"
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import type { Logger } from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
+import {
+ type CmsSearchChange,
+ reconcileContentSearchChange,
+} from "../../../../modules/meilisearch/content-events"
import { PAYLOAD_MODULE } from "../../../../modules/payload"
import type PayloadModuleService from "../../../../modules/payload/service"
import {
@@ -12,7 +16,12 @@ import {
/** Expected webhook payload from Payload CMS invalidation hook. */
type PayloadWebhookBody = {
collection?: string
- doc?: { id?: string; slug?: string; locale?: string }
+ doc?: Record & {
+ id?: string
+ slug?: string
+ locale?: string
+ }
+ operation?: string
}
/** Hook endpoint to invalidate cached CMS content in Medusa. */
@@ -50,6 +59,11 @@ export async function POST(req: MedusaRequest, res: MedusaResponse) {
body.doc?.slug,
body.doc?.locale
)
+ await reconcileContentSearchChange(
+ body as CmsSearchChange,
+ logger,
+ req.scope
+ )
} catch (error) {
logger.error(
`CMS cache invalidation failed (collection="${body.collection}", slug="${body.doc?.slug ?? "n/a"}", locale="${body.doc?.locale ?? "n/a"}")`,
diff --git a/apps/medusa-be/src/api/middlewares.ts b/apps/medusa-be/src/api/middlewares.ts
index 8df35e209a..fbc6eda3a6 100644
--- a/apps/medusa-be/src/api/middlewares.ts
+++ b/apps/medusa-be/src/api/middlewares.ts
@@ -27,6 +27,7 @@ import { adminPromotionsExtensionMiddlewares } from "./admin/promotions/middlewa
import { adminPublishableKeyRoutesMiddlewares } from "./admin/provisioning/publishable-key/middlewares"
import { adminQrPaymentConfigRoutesMiddlewares } from "./admin/qr-payment-config/middlewares"
import { adminReviewRoutesMiddlewares } from "./admin/reviews/middlewares"
+import { adminSearchProfileRoutesMiddlewares } from "./admin/search-profiles/middlewares"
import { adminStorefrontTextRoutesMiddlewares } from "./admin/storefront-texts/middlewares"
import { serveAdminAppStatic } from "./admin-app-static"
import { storeBrandsRoutesMiddlewares } from "./store/brands/middlewares"
@@ -38,6 +39,7 @@ import { storeProductListsRoutesMiddlewares } from "./store/product-lists/middle
import { storeProductLocationAvailabilityRoutesMiddlewares } from "./store/products/[id]/location-availability/middlewares"
import { storeProductAttributesRoutesMiddlewares } from "./store/products/[id]/product-attributes/middlewares"
import { storeReviewRoutesMiddlewares } from "./store/reviews/middlewares"
+import { storeSearchAutocompleteRoutesMiddlewares } from "./store/search/autocomplete/middlewares"
import { storeShopReviewRoutesMiddlewares } from "./store/shop-reviews/middlewares"
import { storeStorefrontTextRoutesMiddlewares } from "./store/storefront-texts/middlewares"
@@ -86,10 +88,12 @@ export default defineMiddlewares({
...adminProductAttributeRoutesMiddlewares,
...adminQrPaymentConfigRoutesMiddlewares,
...adminReviewRoutesMiddlewares,
+ ...adminSearchProfileRoutesMiddlewares,
...adminStorefrontTextRoutesMiddlewares,
...storeMiddlewares,
...storeCustomerReviewRoutesMiddlewares,
...storeCatalogProductsRoutesMiddlewares,
+ ...storeSearchAutocompleteRoutesMiddlewares,
...storeCmsRoutesMiddlewares,
...storeProductListsRoutesMiddlewares,
...storeProductLocationAvailabilityRoutesMiddlewares,
diff --git a/apps/medusa-be/src/api/store/catalog/products/route.ts b/apps/medusa-be/src/api/store/catalog/products/route.ts
index 8947e5d752..a8abc99a71 100644
--- a/apps/medusa-be/src/api/store/catalog/products/route.ts
+++ b/apps/medusa-be/src/api/store/catalog/products/route.ts
@@ -1,5 +1,6 @@
import type { Query } from "@medusajs/framework"
import type { MedusaResponse } from "@medusajs/framework/http"
+import type { Logger } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
ProductStatus,
@@ -10,6 +11,7 @@ import {
wrapProductsWithTaxPrices,
} from "@medusajs/medusa/api/store/products/helpers"
import type { MeiliSearchService } from "@rokmohar/medusa-plugin-meilisearch"
+import { cleanSearchText } from "../../../../modules/meilisearch/documents"
import { isMeilisearchEnabled } from "../../../../modules/meilisearch/env"
import {
extractBrandHandleFromFacetId,
@@ -19,6 +21,19 @@ import {
STATUS_FACET_DEFINITIONS,
STATUS_FACET_LABEL_BY_ID,
} from "../../../../modules/meilisearch/facets/product-facets"
+import {
+ loadSearchProfiles,
+ resolveSearchProfile,
+ type SearchProfile,
+ SearchProfileResolutionError,
+} from "../../../../modules/meilisearch/profiles"
+import {
+ buildProductResultFilter,
+ expandProductsBySearchMatches,
+ getSalesChannelIds,
+ type RankedProductMatch,
+ selectRankedProductIds,
+} from "../../../../modules/meilisearch/search-results"
import {
decorateProductsWithMeasurements,
getMeasurementDecorationOptions,
@@ -29,7 +44,9 @@ import {
buildCatalogFilterExpressions,
type FacetCountItem,
getFacetDistribution,
+ getFacetDistributionFromHits,
getNumericFacetStats,
+ getNumericFacetStatsFromHits,
humanizeFacetHandle,
normalizeBrandParam,
normalizeCategoryIdsParam,
@@ -45,10 +62,6 @@ import {
type StoreCatalogProductsSchemaType,
} from "./validators"
-type MeiliProductHit = {
- id?: string | number
-}
-
type BrandRecord = {
handle?: string
title?: string
@@ -59,6 +72,19 @@ type CategoryRecord = {
name?: string
}
+type ProductWithCalculatedPrices = {
+ id?: unknown
+ search_result?: {
+ variant_id?: unknown
+ }
+ variants?: Array<{
+ id?: unknown
+ calculated_price?: {
+ calculated_amount?: unknown
+ } | null
+ }> | null
+}
+
const FACETS_TO_FETCH = [
"facet_status",
"facet_form",
@@ -121,44 +147,9 @@ const mapFormFacets = (facetCounts: Map): FacetCountItem[] => {
return [...result, ...additionalItems]
}
-const getProductIdFromHit = (hit: unknown): string | undefined => {
- if (!hit || typeof hit !== "object" || Array.isArray(hit)) {
- return
- }
-
- const id = (hit as MeiliProductHit).id
- if (typeof id === "string") {
- return id
- }
- if (Number.isFinite(id)) {
- return String(id)
- }
-
- return
-}
-
const escapeMeiliFilterValue = (value: string): string =>
value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')
-const getSalesChannelIds = (value: unknown): string[] => {
- if (Array.isArray(value)) {
- return value.filter((item): item is string => typeof item === "string")
- }
-
- if (typeof value === "string") {
- return [value]
- }
-
- if (value && typeof value === "object" && !Array.isArray(value)) {
- const inValue = (value as Record).$in
- if (Array.isArray(inValue)) {
- return inValue.filter((item): item is string => typeof item === "string")
- }
- }
-
- return []
-}
-
const buildMeiliOrExpression = (
field: string,
values: string[]
@@ -307,6 +298,79 @@ const mapDynamicFacets = (
}))
)
+const getLowestCalculatedProductPrice = (
+ product: ProductWithCalculatedPrices
+): number | undefined => {
+ const selectedVariantId = product.search_result?.variant_id
+ const selectedVariant = (product.variants ?? []).find(
+ (variant) =>
+ typeof selectedVariantId === "string" &&
+ variant.id === selectedVariantId
+ )
+ const selectedVariantPrice =
+ selectedVariant?.calculated_price?.calculated_amount
+
+ if (
+ typeof selectedVariantPrice === "number" &&
+ Number.isFinite(selectedVariantPrice)
+ ) {
+ return selectedVariantPrice
+ }
+
+ const prices = (product.variants ?? [])
+ .map((variant) => variant.calculated_price?.calculated_amount)
+ .filter(
+ (amount): amount is number =>
+ typeof amount === "number" && Number.isFinite(amount)
+ )
+
+ return prices.length > 0 ? Math.min(...prices) : undefined
+}
+
+const resolveAuthoritativePriceSortDirection = (
+ sort: string
+): 1 | -1 | undefined => {
+ if (sort === "price-asc") {
+ return 1
+ }
+ if (sort === "price-desc") {
+ return -1
+ }
+ return
+}
+
+const selectProductMatchesForHydration = (options: {
+ cleanedQuery: string
+ limit: number
+ matchingProducts: RankedProductMatch[]
+ offset: number
+ priceSortDirection?: 1 | -1
+}): RankedProductMatch[] => {
+ if (options.priceSortDirection) {
+ return options.matchingProducts
+ }
+ if (options.cleanedQuery) {
+ return options.matchingProducts.slice(
+ options.offset,
+ options.offset + options.limit
+ )
+ }
+ return options.matchingProducts
+}
+
+const resolveResultCount = (options: {
+ estimatedTotalHits?: number
+ exhaustiveCandidateSearch: boolean
+ fallbackCount: number
+ matchingCount: number
+}): number => {
+ if (options.exhaustiveCandidateSearch) {
+ return options.matchingCount
+ }
+ return options.estimatedTotalHits ?? options.fallbackCount
+}
+
+// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This route coordinates search, authoritative hydration, facets, pricing, and degraded fallback.
export async function GET(
req: RequestWithContext,
res: MedusaResponse
@@ -327,8 +391,29 @@ export async function GET(
const meilisearchService = req.scope.resolve(MEILISEARCH)
const page = validatedQuery.page
- const limit = validatedQuery.limit
+ const salesChannelIds = getSalesChannelIds(
+ req.filterableFields.sales_channel_id
+ )
+ let searchProfile: SearchProfile
+ try {
+ searchProfile = resolveSearchProfile(
+ {
+ locale: validatedQuery.locale,
+ requestedKey: validatedQuery.profile,
+ salesChannelIds,
+ },
+ await loadSearchProfiles(req.scope)
+ )
+ } catch (error) {
+ if (error instanceof SearchProfileResolutionError) {
+ res.status(400).json({ message: error.message })
+ return
+ }
+ throw error
+ }
+ const limit = Math.min(validatedQuery.limit, searchProfile.limits.page)
const offset = (page - 1) * limit
+ const cleanedQuery = cleanSearchText(validatedQuery.q)
const categoryIds = normalizeCategoryIdsParam(validatedQuery.category_id)
const statusIds = normalizeStatusParam(validatedQuery.status)
@@ -346,35 +431,154 @@ export async function GET(
priceMax: validatedQuery.price_max,
})
- const sort = resolveCatalogSort(validatedQuery.sort)
+ const authoritativePriceSortDirection =
+ resolveAuthoritativePriceSortDirection(validatedQuery.sort)
+ const exhaustiveCandidateSearch = Boolean(
+ cleanedQuery || authoritativePriceSortDirection
+ )
+ const sort =
+ resolveCatalogSort(validatedQuery.sort) ??
+ (cleanedQuery ? undefined : ["facet_popularity:desc"])
+ const meiliSort = authoritativePriceSortDirection ? undefined : sort
const searchFilters = [
+ buildProductResultFilter(
+ searchProfile.separateVariantResults,
+ cleanedQuery
+ ),
...filterExpressions,
...buildVisibilityFilterExpressions(req.filterableFields.sales_channel_id),
]
const searchFilter =
searchFilters.length > 0 ? searchFilters.join(" AND ") : undefined
- const searchResult = await meilisearchService.search(
- "products",
- validatedQuery.q.trim(),
- {
- paginationOptions: {
- limit,
- offset,
+ let searchResult: Awaited>
+ try {
+ searchResult = await meilisearchService.search(
+ searchProfile.indexes.product,
+ cleanedQuery,
+ {
+ paginationOptions: {
+ limit: exhaustiveCandidateSearch
+ ? searchProfile.limits.fullSearch
+ : limit,
+ offset: exhaustiveCandidateSearch ? 0 : offset,
+ },
+ filter: searchFilter,
+ additionalOptions: {
+ attributesToRetrieve: [
+ "id",
+ "title",
+ "brand",
+ "search_product_id",
+ "search_variant_id",
+ "search_variant_title",
+ "search_variant_titles",
+ "search_identifiers_normalized",
+ "facet_status",
+ "facet_form",
+ "facet_brand",
+ "facet_ingredient",
+ "facet_price",
+ ],
+ facets: FACETS_TO_FETCH,
+ ...(cleanedQuery ? { showRankingScore: true } : {}),
+ ...(meiliSort ? { sort: meiliSort } : {}),
+ },
+ }
+ )
+ } catch (error) {
+ const logger = req.scope.resolve(ContainerRegistrationKeys.LOGGER)
+ logger.warn(
+ `Meilisearch catalog query failed; using capped Medusa fallback: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ )
+ const pricingContext = req.pricingContext
+ ? QueryContext(req.pricingContext)
+ : undefined
+ const productFields = pricingContext
+ ? [
+ ...STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS,
+ ...STORE_CATALOG_PRODUCTS_PRICING_FIELDS,
+ ]
+ : STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS
+ const { data: fallbackProducts, metadata: fallbackMetadata } = await queryService.graph({
+ entity: "product",
+ fields: productFields,
+ filters: await normalizeProductSalesChannelFilter(
+ queryService,
+ remoteQuery,
+ {
+ ...(cleanedQuery ? { q: cleanedQuery } : {}),
+ sales_channel_id: req.filterableFields.sales_channel_id,
+ status: ProductStatus.PUBLISHED,
+ }
+ ),
+ pagination: {
+ take: limit,
+ skip: offset,
+ },
+ context: pricingContext
+ ? {
+ variants: {
+ calculated_price: pricingContext,
+ },
+ }
+ : undefined,
+ })
+
+ await wrapProductsWithTaxPrices(
+ req,
+ fallbackProducts as Parameters[1]
+ )
+ await decorateProductsWithMeasurements(
+ req.scope,
+ fallbackProducts as Parameters<
+ typeof decorateProductsWithMeasurements
+ >[1],
+ measurementDecorationOptions
+ )
+ res.json({
+ products: fallbackProducts,
+ count: fallbackMetadata?.count ?? fallbackProducts.length,
+ page,
+ limit,
+ totalPages: Math.ceil((fallbackMetadata?.count ?? fallbackProducts.length) / limit),
+ facets: {
+ status: mapStatusFacets(new Map()),
+ form: mapFormFacets(new Map()),
+ brand: [],
+ ingredient: [],
+ price: {
+ min: null,
+ max: null,
+ },
},
- filter: searchFilter,
- additionalOptions: {
- attributesToRetrieve: ["id"],
- facets: FACETS_TO_FETCH,
- ...(sort ? { sort } : {}),
+ search: {
+ degraded: true,
+ exactIdentifierMatch: false,
+ profile: searchProfile.key,
},
- }
- )
+ })
+ return
+ }
- const productIds = Array.isArray(searchResult.hits)
- ? searchResult.hits
- .map((hit) => getProductIdFromHit(hit))
- .filter((id): id is string => Boolean(id))
- : []
+ const rankedProducts = selectRankedProductIds(
+ searchResult.hits,
+ cleanedQuery,
+ searchProfile.minimumRankingScore,
+ searchProfile.strict
+ )
+ const matchingProducts = rankedProducts.matches
+ const productMatches = selectProductMatchesForHydration({
+ cleanedQuery,
+ limit,
+ matchingProducts,
+ offset,
+ priceSortDirection: authoritativePriceSortDirection,
+ })
+ const productIds = Array.from(
+ new Set(productMatches.map((match) => match.productId))
+ )
const pricingContext = req.pricingContext
? QueryContext(req.pricingContext)
: undefined
@@ -411,36 +615,62 @@ export async function GET(
: undefined,
})
- const productOrder = new Map(productIds.map((id, index) => [id, index]))
- const orderedProducts = [...products].sort((left, right) => {
- const leftId = typeof left.id === "string" ? left.id : ""
- const rightId = typeof right.id === "string" ? right.id : ""
- const leftIndex = productOrder.get(leftId) ?? Number.MAX_SAFE_INTEGER
- const rightIndex = productOrder.get(rightId) ?? Number.MAX_SAFE_INTEGER
- return leftIndex - rightIndex
- })
-
- const statusFacetCounts = getFacetDistribution(
- searchResult.facetDistribution,
- "facet_status"
- )
- const formFacetCounts = getFacetDistribution(
- searchResult.facetDistribution,
- "facet_form"
- )
- const brandFacetCounts = getFacetDistribution(
- searchResult.facetDistribution,
- "facet_brand"
+ const orderedProducts = expandProductsBySearchMatches(
+ products as Record[],
+ productMatches
)
- const ingredientFacetCounts = getFacetDistribution(
- searchResult.facetDistribution,
- "facet_ingredient"
- )
- const priceFacetStats = getNumericFacetStats(
- searchResult.facetStats,
- "facet_price"
+ await wrapProductsWithTaxPrices(
+ req,
+ orderedProducts as unknown as Parameters[1]
)
+ const finalProducts = authoritativePriceSortDirection
+ ? [...orderedProducts]
+ .sort((left, right) => {
+ const leftPrice = getLowestCalculatedProductPrice(
+ left as ProductWithCalculatedPrices
+ )
+ const rightPrice = getLowestCalculatedProductPrice(
+ right as ProductWithCalculatedPrices
+ )
+
+ if (leftPrice === undefined && rightPrice === undefined) {
+ return 0
+ }
+ if (leftPrice === undefined) {
+ return 1
+ }
+ if (rightPrice === undefined) {
+ return -1
+ }
+
+ return (leftPrice - rightPrice) * authoritativePriceSortDirection
+ })
+ .slice(offset, offset + limit)
+ : orderedProducts
+
+ const statusFacetCounts = cleanedQuery
+ ? getFacetDistributionFromHits(rankedProducts.selectedHits, "facet_status")
+ : getFacetDistribution(searchResult.facetDistribution, "facet_status")
+ const formFacetCounts = cleanedQuery
+ ? getFacetDistributionFromHits(rankedProducts.selectedHits, "facet_form")
+ : getFacetDistribution(searchResult.facetDistribution, "facet_form")
+ const brandFacetCounts = cleanedQuery
+ ? getFacetDistributionFromHits(rankedProducts.selectedHits, "facet_brand")
+ : getFacetDistribution(searchResult.facetDistribution, "facet_brand")
+ const ingredientFacetCounts = cleanedQuery
+ ? getFacetDistributionFromHits(
+ rankedProducts.selectedHits,
+ "facet_ingredient"
+ )
+ : getFacetDistribution(searchResult.facetDistribution, "facet_ingredient")
+ const priceFacetStats = cleanedQuery
+ ? getNumericFacetStatsFromHits(
+ rankedProducts.selectedHits,
+ "facet_price"
+ )
+ : getNumericFacetStats(searchResult.facetStats, "facet_price")
+
const [brandLabelsById, ingredientLabelsById] = await Promise.all([
resolveBrandFacetLabels(queryService, Array.from(brandFacetCounts.keys())),
resolveIngredientFacetLabels(
@@ -449,27 +679,30 @@ export async function GET(
),
])
- const count =
- typeof searchResult.estimatedTotalHits === "number"
- ? searchResult.estimatedTotalHits
- : orderedProducts.length
+ const count = resolveResultCount({
+ estimatedTotalHits: searchResult.estimatedTotalHits,
+ exhaustiveCandidateSearch,
+ fallbackCount: finalProducts.length,
+ matchingCount: matchingProducts.length,
+ })
const totalPages = count > 0 ? Math.ceil(count / limit) : 0
- await wrapProductsWithTaxPrices(
- req,
- orderedProducts as Parameters[1]
- )
await decorateProductsWithMeasurements(
req.scope,
- orderedProducts as Parameters[1],
+ finalProducts as Parameters[1],
measurementDecorationOptions
)
res.json({
- products: orderedProducts,
+ products: finalProducts,
count,
page,
limit,
totalPages,
+ search: {
+ degraded: false,
+ exactIdentifierMatch: rankedProducts.exactIdentifierMatch,
+ profile: searchProfile.key,
+ },
facets: {
status: mapStatusFacets(statusFacetCounts),
form: mapFormFacets(formFacetCounts),
diff --git a/apps/medusa-be/src/api/store/catalog/products/utils.ts b/apps/medusa-be/src/api/store/catalog/products/utils.ts
index 927e80fb39..4da60d717b 100644
--- a/apps/medusa-be/src/api/store/catalog/products/utils.ts
+++ b/apps/medusa-be/src/api/store/catalog/products/utils.ts
@@ -152,7 +152,7 @@ export const resolveCatalogSort = (
): string[] | undefined => {
switch (sort) {
case "best-selling":
- return
+ return ["facet_popularity:desc"]
case "newest":
return ["created_at:desc"]
case "oldest":
@@ -256,6 +256,36 @@ export const getFacetDistribution = (
return result
}
+export const getFacetDistributionFromHits = (
+ hits: unknown,
+ facetKey: string
+): Map => {
+ if (!Array.isArray(hits)) {
+ return new Map()
+ }
+
+ const result = new Map()
+ for (const hit of hits) {
+ if (!hit || typeof hit !== "object" || Array.isArray(hit)) {
+ continue
+ }
+
+ const rawFacet = (hit as Record)[facetKey]
+ const facetValues = Array.isArray(rawFacet) ? rawFacet : [rawFacet]
+ const uniqueValues = new Set(
+ facetValues.filter(
+ (value): value is string => typeof value === "string" && Boolean(value)
+ )
+ )
+
+ for (const value of uniqueValues) {
+ result.set(value, (result.get(value) ?? 0) + 1)
+ }
+ }
+
+ return result
+}
+
export const getNumericFacetStats = (
facetStats: unknown,
facetKey: string
@@ -283,6 +313,30 @@ export const getNumericFacetStats = (
return { min, max }
}
+export const getNumericFacetStatsFromHits = (
+ hits: unknown,
+ facetKey: string
+): { min?: number; max?: number } => {
+ if (!Array.isArray(hits)) {
+ return {}
+ }
+
+ const values = hits
+ .map((hit) =>
+ hit && typeof hit === "object" && !Array.isArray(hit)
+ ? (hit as Record)[facetKey]
+ : undefined
+ )
+ .filter(
+ (value): value is number =>
+ typeof value === "number" && Number.isFinite(value)
+ )
+
+ return values.length > 0
+ ? { min: Math.min(...values), max: Math.max(...values) }
+ : {}
+}
+
export const humanizeFacetHandle = (handle: string): string =>
handle.replaceAll("-", " ").replaceAll(/\s+/g, " ").trim()
diff --git a/apps/medusa-be/src/api/store/catalog/products/validators.ts b/apps/medusa-be/src/api/store/catalog/products/validators.ts
index 35c59f6d39..bb5fd56889 100644
--- a/apps/medusa-be/src/api/store/catalog/products/validators.ts
+++ b/apps/medusa-be/src/api/store/catalog/products/validators.ts
@@ -11,6 +11,11 @@ export const STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS = [
"thumbnail",
"metadata",
"variants.id",
+ "variants.title",
+ "variants.sku",
+ "variants.ean",
+ "variants.upc",
+ "variants.barcode",
"categories.id",
"categories.name",
"categories.handle",
@@ -44,6 +49,8 @@ export const StoreCatalogProductsSchema = z
.object({
fields: z.string().optional(),
q: z.string().optional().default(""),
+ profile: z.string().trim().min(1).max(120).optional(),
+ locale: z.string().trim().min(2).max(20).optional(),
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(48).optional().default(12),
sort: z.enum(CATALOG_SORT_VALUES).optional().default("recommended"),
diff --git a/apps/medusa-be/src/api/store/search/autocomplete/middlewares.ts b/apps/medusa-be/src/api/store/search/autocomplete/middlewares.ts
new file mode 100644
index 0000000000..29a898694d
--- /dev/null
+++ b/apps/medusa-be/src/api/store/search/autocomplete/middlewares.ts
@@ -0,0 +1,26 @@
+import { validateAndTransformQuery } from '@medusajs/framework'
+import { applyDefaultFilters, authenticate, type MiddlewareRoute } from '@medusajs/framework/http'
+import { ProductStatus } from '@medusajs/framework/utils'
+import { filterByValidSalesChannels } from '@medusajs/medusa/api/utils/middlewares/products/filter-by-valid-sales-channels'
+import { normalizeDataForContext } from '@medusajs/medusa/api/utils/middlewares/products/normalize-data-for-context'
+import { setPricingContext } from '@medusajs/medusa/api/utils/middlewares/products/set-pricing-context'
+import { setTaxContext } from '@medusajs/medusa/api/utils/middlewares/products/set-tax-context'
+import { STORE_CATALOG_PRODUCTS_ALLOWED_FIELDS, STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS } from '../../catalog/products/validators'
+import { StoreSearchAutocompleteSchema } from './validators'
+
+export const storeSearchAutocompleteRoutesMiddlewares: MiddlewareRoute[] = [
+ {
+ methods: ['GET'],
+ matcher: '/store/search/autocomplete',
+
+ middlewares: [
+ authenticate('customer', ['session', 'bearer'], { allowUnauthenticated: true }),
+ validateAndTransformQuery(StoreSearchAutocompleteSchema, { defaults: STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS, allowed: STORE_CATALOG_PRODUCTS_ALLOWED_FIELDS, isList: true }),
+ filterByValidSalesChannels(),
+ applyDefaultFilters({ status: ProductStatus.PUBLISHED }),
+ normalizeDataForContext(),
+ setPricingContext(),
+ setTaxContext()
+ ]
+ }
+]
diff --git a/apps/medusa-be/src/api/store/search/autocomplete/route.ts b/apps/medusa-be/src/api/store/search/autocomplete/route.ts
new file mode 100644
index 0000000000..e04eff9270
--- /dev/null
+++ b/apps/medusa-be/src/api/store/search/autocomplete/route.ts
@@ -0,0 +1,176 @@
+import type { Query } from '@medusajs/framework'
+import type { MedusaResponse } from '@medusajs/framework/http'
+import { ContainerRegistrationKeys, ProductStatus, QueryContext } from '@medusajs/framework/utils'
+import { type RequestWithContext, wrapProductsWithTaxPrices } from '@medusajs/medusa/api/store/products/helpers'
+import type { MeiliSearchService } from '@rokmohar/medusa-plugin-meilisearch'
+import { cleanSearchText } from '../../../../modules/meilisearch/documents'
+import { isMeilisearchEnabled } from '../../../../modules/meilisearch/env'
+import { loadSearchProfiles, resolveSearchProfile, type SearchProfile, SearchProfileResolutionError } from '../../../../modules/meilisearch/profiles'
+import { buildProductResultFilter, expandProductsBySearchMatches, getSalesChannelIds, selectRankedProductIds } from '../../../../modules/meilisearch/search-results'
+import { MEILISEARCH } from '../../../../workflows/meilisearch'
+import { normalizeProductSalesChannelFilter } from '../../../utils/product-filters'
+import { STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS, STORE_CATALOG_PRODUCTS_PRICING_FIELDS } from '../../catalog/products/validators'
+import type { StoreSearchAutocompleteSchemaType } from './validators'
+
+type SearchResponse = {
+ hits?: unknown[]
+}
+
+const escapeFilterValue = (value: string): string => value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
+
+const buildVisibilityFilter = (salesChannelIds: string[], resultFilter: string): string => {
+ const filters = ['facet_product_status = "' + ProductStatus.PUBLISHED + '"', resultFilter]
+
+ if (salesChannelIds.length === 1) {
+ filters.push('facet_sales_channel_ids = "' + escapeFilterValue(salesChannelIds[0] ?? '') + '"')
+ } else if (salesChannelIds.length > 1) {
+ filters.push('(' + salesChannelIds.map((id) => 'facet_sales_channel_ids = "' + escapeFilterValue(id) + '"').join(' OR ') + ')')
+ }
+
+ return filters.join(' AND ')
+}
+
+const safeSearch = async (
+ service: MeiliSearchService,
+ index: string,
+ query: string,
+ options: Parameters[2]
+): Promise => {
+ try {
+ return await service.search(index, query, options)
+ } catch {
+ return null
+ }
+}
+
+const getStringId = (value: unknown): string | undefined => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return
+ }
+
+ const id = (value as { id?: unknown }).id
+
+ if (typeof id === 'string') {
+ return id
+ }
+
+ if (typeof id === 'number' && Number.isFinite(id)) {
+ return String(id)
+ }
+
+ return
+}
+
+const deduplicateHits = (hits: unknown[] | undefined, field: string): unknown[] => {
+ const seen = new Set()
+ const result: unknown[] = []
+
+ for (const hit of hits ?? []) {
+ if (!hit || typeof hit !== 'object' || Array.isArray(hit)) {
+ continue
+ }
+
+ const value = (hit as Record)[field]
+ const key = typeof value === 'string' ? cleanSearchText(value).toLocaleLowerCase() : getStringId(hit)
+
+ if (!key || !seen.has(key)) {
+ if (key) {
+ seen.add(key)
+ }
+
+ result.push(hit)
+ }
+ }
+
+ return result
+}
+
+// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This endpoint coordinates four independent indices, authoritative product hydration, and degraded fallback.
+export async function GET(request: RequestWithContext, response: MedusaResponse) {
+ if (!isMeilisearchEnabled()) {
+ response.status(503).json({ message: 'Catalog search is disabled' })
+
+ return
+ }
+
+ const query = cleanSearchText(request.validatedQuery.q)
+ const salesChannelIds = getSalesChannelIds(request.filterableFields.sales_channel_id)
+
+ let profile: SearchProfile
+
+ try {
+ profile = resolveSearchProfile({ locale: request.validatedQuery.locale, requestedKey: request.validatedQuery.profile, salesChannelIds: salesChannelIds }, await loadSearchProfiles(request.scope))
+ } catch (error) {
+ if (error instanceof SearchProfileResolutionError) {
+ response.status(400).json({ message: error.message })
+
+ return
+ }
+
+ throw error
+ }
+
+ const meilisearch = request.scope.resolve(MEILISEARCH)
+ const productCandidateLimit = query ? Math.min(profile.limits.fullSearch, Math.max(profile.limits.autocomplete.product * 4, 24)) : profile.limits.popular
+ const emptyEntitySearch = Promise.resolve({ hits: [] })
+ const productResultFilter = buildProductResultFilter(profile.separateVariantResults, query)
+
+ const [productSearch, categorySearch, brandSearch, contentSearch] = await Promise.all([
+ safeSearch(meilisearch, profile.indexes.product, query, { paginationOptions: { limit: productCandidateLimit, offset: 0 }, filter: buildVisibilityFilter(salesChannelIds, productResultFilter), additionalOptions: { attributesToRetrieve: ['id', 'title', 'brand', 'search_product_id', 'search_variant_id', 'search_variant_title', 'search_variant_titles', 'search_identifiers_normalized'], ...(query ? { showRankingScore: true } : { sort: ['facet_popularity:desc'] }) } }),
+ query ? safeSearch(meilisearch, profile.indexes.category, query, { paginationOptions: { limit: profile.limits.autocomplete.category, offset: 0 }, additionalOptions: { attributesToRetrieve: ['id', 'name', 'handle'] } }) : emptyEntitySearch,
+ query ? safeSearch(meilisearch, profile.indexes.brand, query, { paginationOptions: { limit: profile.limits.autocomplete.brand, offset: 0 }, additionalOptions: { attributesToRetrieve: ['id', 'title', 'handle'] } }) : emptyEntitySearch,
+ query ? safeSearch(meilisearch, profile.indexes.content, query, { paginationOptions: { limit: profile.limits.autocomplete.content, offset: 0 }, additionalOptions: { attributesToRetrieve: ['id', 'type', 'title', 'excerpt', 'href'] } }) : emptyEntitySearch
+ ])
+
+ const rankedProducts = selectRankedProductIds(productSearch?.hits, query, profile.minimumRankingScore, profile.strict)
+ const productResultLimit = query ? profile.limits.autocomplete.product : profile.limits.popular
+ const productMatches = rankedProducts.matches.slice(0, productResultLimit)
+ const productIds = [...new Set(productMatches.map((match) => match.productId))]
+ const queryService = request.scope.resolve(ContainerRegistrationKeys.QUERY)
+ const remoteQuery = request.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
+ const pricingContext = request.pricingContext ? QueryContext(request.pricingContext) : undefined
+ const productFields = pricingContext ? [...STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS, ...STORE_CATALOG_PRODUCTS_PRICING_FIELDS] : STORE_CATALOG_PRODUCTS_DEFAULT_FIELDS
+
+ let degraded = productSearch === null
+ let products: Record[] = []
+
+ if (productIds.length > 0) {
+ const result = await queryService.graph({ entity: 'product', fields: productFields, filters: await normalizeProductSalesChannelFilter(queryService, remoteQuery, { id: { $in: productIds }, sales_channel_id: request.filterableFields.sales_channel_id, status: ProductStatus.PUBLISHED }), context: pricingContext ? { variants: { calculated_price: pricingContext } } : undefined })
+
+ products = expandProductsBySearchMatches(result.data as Record[], productMatches)
+ } else if (!productSearch) {
+ const result = await queryService.graph({
+ entity: 'product',
+ fields: productFields,
+ filters: await normalizeProductSalesChannelFilter(queryService, remoteQuery, { q: query, sales_channel_id: request.filterableFields.sales_channel_id, status: ProductStatus.PUBLISHED }),
+
+ pagination: {
+ take: productResultLimit,
+ skip: 0
+ },
+
+ context: pricingContext ? {
+ variants: {
+ calculated_price: pricingContext
+ }
+ } : undefined
+ })
+
+ products = result.data as Record[]
+ }
+
+ await wrapProductsWithTaxPrices(request, products as unknown as Parameters[1])
+
+ degraded ||= [categorySearch, brandSearch, contentSearch].some((result) => result === null)
+
+ response.json({
+ query: query,
+ profile: profile.key,
+ degraded: degraded,
+ exactIdentifierMatch: rankedProducts.exactIdentifierMatch,
+ products: products,
+ categories: deduplicateHits(categorySearch?.hits, 'name'),
+ brands: deduplicateHits(brandSearch?.hits, 'title'),
+ content: deduplicateHits(contentSearch?.hits, 'href')
+ })
+}
diff --git a/apps/medusa-be/src/api/store/search/autocomplete/validators.ts b/apps/medusa-be/src/api/store/search/autocomplete/validators.ts
new file mode 100644
index 0000000000..7a6141723d
--- /dev/null
+++ b/apps/medusa-be/src/api/store/search/autocomplete/validators.ts
@@ -0,0 +1,15 @@
+import { z as zod } from '@medusajs/framework/zod'
+
+export const StoreSearchAutocompleteSchema = zod
+ .object({
+ q: zod.string().trim().max(120),
+ profile: zod.string().trim().min(1).max(120).optional(),
+ locale: zod.string().trim().min(2).max(20).optional(),
+ region_id: zod.string().optional(),
+ currency_code: zod.string().optional(),
+ country_code: zod.string().optional(),
+ sales_channel_id: zod.union([zod.string(), zod.array(zod.string())]).optional()
+ })
+ .strict()
+
+export type StoreSearchAutocompleteSchemaType = zod.infer
diff --git a/apps/medusa-be/src/config/modules.ts b/apps/medusa-be/src/config/modules.ts
index 63f03b9694..254de67e8e 100644
--- a/apps/medusa-be/src/config/modules.ts
+++ b/apps/medusa-be/src/config/modules.ts
@@ -247,6 +247,10 @@ export function buildModules(env: MedusaConfigEnv): MedusaModulesConfig {
{
resolve: "./src/modules/product-review",
},
+ {
+ resolve: "./src/modules/search-profile",
+ dependencies: [Modules.CACHING],
+ },
{
resolve: "./src/modules/storefront-text",
},
diff --git a/apps/medusa-be/src/jobs/meilisearch-full-sync.ts b/apps/medusa-be/src/jobs/meilisearch-full-sync.ts
new file mode 100644
index 0000000000..9929115004
--- /dev/null
+++ b/apps/medusa-be/src/jobs/meilisearch-full-sync.ts
@@ -0,0 +1,11 @@
+import type { MedusaContainer } from '@medusajs/framework/types'
+import { synchronizeSearchProfiles } from '../modules/meilisearch/synchronize'
+
+export default async function meilisearchFullSyncJob(container: MedusaContainer) {
+ await synchronizeSearchProfiles(container, 'full')
+}
+
+export const config = {
+ name: 'meilisearch-full-sync',
+ schedule: '0 3 * * *'
+}
diff --git a/apps/medusa-be/src/jobs/meilisearch-normal-sync.ts b/apps/medusa-be/src/jobs/meilisearch-normal-sync.ts
new file mode 100644
index 0000000000..60c6cd5f54
--- /dev/null
+++ b/apps/medusa-be/src/jobs/meilisearch-normal-sync.ts
@@ -0,0 +1,11 @@
+import type { MedusaContainer } from '@medusajs/framework/types'
+import { synchronizeSearchProfiles } from '../modules/meilisearch/synchronize'
+
+export default async function meilisearchNormalSyncJob(container: MedusaContainer) {
+ await synchronizeSearchProfiles(container, 'normal')
+}
+
+export const config = {
+ name: 'meilisearch-normal-sync',
+ schedule: '0 */4 * * *'
+}
diff --git a/apps/medusa-be/src/jobs/meilisearch-search-profiles-index.ts b/apps/medusa-be/src/jobs/meilisearch-search-profiles-index.ts
new file mode 100644
index 0000000000..98f64f8fb1
--- /dev/null
+++ b/apps/medusa-be/src/jobs/meilisearch-search-profiles-index.ts
@@ -0,0 +1,12 @@
+import type { MedusaContainer } from '@medusajs/framework/types'
+import { synchronizeSearchProfiles } from '../modules/meilisearch/synchronize'
+
+export default async function meilisearchSearchProfilesIndexJob(container: MedusaContainer) {
+ await synchronizeSearchProfiles(container, 'normal')
+}
+
+export const config = {
+ name: 'meilisearch-search-profiles-index',
+ schedule: '* * * * *',
+ numberOfExecutions: 1
+}
diff --git a/apps/medusa-be/src/modules/meilisearch/admin-client.ts b/apps/medusa-be/src/modules/meilisearch/admin-client.ts
new file mode 100644
index 0000000000..d465cd921e
--- /dev/null
+++ b/apps/medusa-be/src/modules/meilisearch/admin-client.ts
@@ -0,0 +1,261 @@
+import { MedusaError } from '@medusajs/framework/utils'
+
+type MeilisearchTask = {
+ error?: {
+ message?: string
+ }
+
+ status?: string
+ taskUid?: number
+ uid?: number
+}
+
+type MeilisearchDocumentsResponse = {
+ results?: Array<{
+ id?: string | number
+ }>
+}
+
+type MeilisearchDocumentResponse = {
+ id?: string | number
+}
+
+type MeilisearchHealthResponse = {
+ status?: string
+}
+
+type MeilisearchRequestOptions = {
+ acceptedStatuses?: number[]
+ attempts?: number
+ body?: unknown
+ method: string
+ path: string
+}
+
+class MeilisearchHttpError extends Error {
+ readonly retryable: boolean
+
+ constructor(message: string, retryable: boolean) {
+ super(message)
+
+ this.retryable = retryable
+ }
+}
+
+const DEFAULT_TASK_TIMEOUT_MILLISECONDS = 60000
+const TASK_POLL_INTERVAL_MILLISECONDS = 75
+const REQUEST_TIMEOUT_MILLISECONDS = 10000
+const REQUEST_ATTEMPTS = 3
+const TRAILING_SLASH_REGEX = /\/$/
+
+export class MeilisearchAdminClient {
+ private readonly apiKey: string
+ private readonly baseUrl: string
+
+ constructor(options?: { apiKey?: string; host?: string }) {
+ const host = options?.host ?? process.env.MEILISEARCH_HOST
+ const apiKey = options?.apiKey ?? process.env.MEILISEARCH_API_KEY
+
+ if (!host) {
+ throw new MedusaError(MedusaError.Types.INVALID_DATA, 'MEILISEARCH_HOST is required for profile indexing')
+ }
+
+ if (!apiKey) {
+ throw new MedusaError(MedusaError.Types.INVALID_DATA, 'MEILISEARCH_API_KEY is required for profile indexing')
+ }
+
+ this.baseUrl = host.replace(TRAILING_SLASH_REGEX, '')
+ this.apiKey = apiKey
+ }
+
+ private async requestOnce(options: MeilisearchRequestOptions): Promise {
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MILLISECONDS)
+
+ try {
+ const response = await fetch(this.baseUrl + options.path, { method: options.method, headers: { Authorization: 'Bearer ' + this.apiKey, ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: options.body === undefined ? undefined : JSON.stringify(options.body), signal: controller.signal })
+ const raw = await response.text()
+
+ let parsed: unknown
+
+ try {
+ parsed = raw ? JSON.parse(raw) : undefined
+ } catch {
+ parsed = undefined
+ }
+
+ if (response.ok || (options.acceptedStatuses ?? []).includes(response.status)) {
+ return parsed as ResponseBody
+ }
+
+ const parsedMessage = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && typeof (parsed as { message?: unknown }).message === 'string' ? (parsed as { message: string }).message : raw
+
+ throw new MeilisearchHttpError('Meilisearch ' + options.method + ' ' + options.path + ' failed (' + response.status + '): ' + parsedMessage, response.status === 429 || response.status >= 500)
+ } finally {
+ clearTimeout(timeoutId)
+ }
+ }
+
+ private async request(options: MeilisearchRequestOptions): Promise {
+ const attempts = options.attempts ?? REQUEST_ATTEMPTS
+
+ let lastError: unknown
+
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ try {
+ return await this.requestOnce(options)
+ } catch (error) {
+ lastError = error
+
+ if (error instanceof MeilisearchHttpError && !error.retryable) {
+ throw error
+ }
+ }
+
+ if (attempt + 1 < attempts) {
+ await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt))
+ }
+ }
+
+ throw lastError ?? new Error('Meilisearch ' + options.method + ' ' + options.path + ' failed without a response')
+ }
+
+ private async enqueue(options: MeilisearchRequestOptions): Promise {
+ const task = await this.request(options)
+ const taskUid = task.taskUid ?? task.uid
+
+ if (typeof taskUid === 'number') {
+ await this.waitForTask(taskUid)
+ }
+ }
+
+ async waitForTask(taskUid: number, timeoutMilliseconds = DEFAULT_TASK_TIMEOUT_MILLISECONDS): Promise {
+ const deadline = Date.now() + timeoutMilliseconds
+
+ while (Date.now() < deadline) {
+ const task = await this.request({ method: 'GET', path: '/tasks/' + taskUid, acceptedStatuses: [404] })
+
+ if (task.status === 'succeeded') {
+ return
+ }
+
+ if (task.status === 'failed' || task.status === 'canceled') {
+ throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, 'Meilisearch task ' + taskUid + ' ' + task.status + ': ' + (task.error?.message ?? 'unknown error'))
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, TASK_POLL_INTERVAL_MILLISECONDS))
+ }
+
+ throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, 'Meilisearch task ' + taskUid + ' timed out after ' + timeoutMilliseconds + 'ms')
+ }
+
+ async ensureIndex(index: string): Promise {
+ try {
+ await this.enqueue({ method: 'POST', path: '/indexes', body: { uid: index, primaryKey: 'id' } })
+ } catch (error) {
+ if (error instanceof Error && (error.message.includes('index_already_exists') || error.message.toLowerCase().includes('already exists'))) {
+ return
+ }
+
+ throw error
+ }
+ }
+
+ async health(): Promise {
+ return this.request({ method: 'GET', path: '/health', attempts: 1 })
+ }
+
+ async updateSettings(index: string, settings: Record): Promise {
+ await this.enqueue({ method: 'PATCH', path: '/indexes/' + encodeURIComponent(index) + '/settings', body: settings })
+ }
+
+ async addDocuments(index: string, documents: Record[]): Promise {
+ if (documents.length === 0) {
+ return
+ }
+
+ await this.enqueue({ method: 'POST', path: '/indexes/' + encodeURIComponent(index) + '/documents?primaryKey=id', body: documents })
+ }
+
+ async deleteDocuments(index: string, ids: string[]): Promise {
+ if (ids.length === 0) {
+ return
+ }
+
+ await this.enqueue({ method: 'POST', path: '/indexes/' + encodeURIComponent(index) + '/documents/delete-batch', body: ids })
+ }
+
+ async getDocumentIds(index: string, batchSize = 1000): Promise {
+ const ids: string[] = []
+
+ let offset = 0
+
+ while (true) {
+ const result = await this.request({ method: 'GET', path: '/indexes/' + encodeURIComponent(index) + '/documents?fields=id&limit=' + batchSize + '&offset=' + offset })
+ const batch = result.results ?? []
+
+ for (const document of batch) {
+ if (typeof document.id === 'string') {
+ ids.push(document.id)
+ } else if (typeof document.id === 'number') {
+ ids.push(String(document.id))
+ }
+ }
+
+ if (batch.length < batchSize) {
+ break
+ }
+
+ offset += batch.length
+ }
+
+ return ids
+ }
+
+ async waitForDocument(index: string, documentId: string, timeoutMilliseconds = DEFAULT_TASK_TIMEOUT_MILLISECONDS): Promise {
+ const deadline = Date.now() + timeoutMilliseconds
+
+ while (Date.now() < deadline) {
+ const document = await this.request({ method: 'GET', path: '/indexes/' + encodeURIComponent(index) + '/documents/' + encodeURIComponent(documentId), acceptedStatuses: [404], attempts: 1 })
+
+ if (String(document.id) === documentId) {
+ return
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, TASK_POLL_INTERVAL_MILLISECONDS))
+ }
+
+ throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, 'Meilisearch document ' + documentId + ' did not become visible in ' + index + ' after ' + timeoutMilliseconds + 'ms')
+ }
+
+ async swapIndexPairs(
+ pairs: Array<{ first: string; second: string }>,
+
+ completionProbe?: {
+ documentId: string
+ index: string
+ }
+ ): Promise {
+ if (pairs.length === 0) {
+ return
+ }
+
+ const task = await this.request({ method: 'POST', path: '/swap-indexes', body: pairs.map(({ first, second }) => ({ indexes: [first, second] })), attempts: 1 })
+
+ if (completionProbe) {
+ await this.waitForDocument(completionProbe.index, completionProbe.documentId)
+
+ return
+ }
+
+ const taskUid = task.taskUid ?? task.uid
+
+ if (typeof taskUid === 'number') {
+ await this.waitForTask(taskUid)
+ }
+ }
+
+ async deleteIndex(index: string): Promise {
+ await this.enqueue({ method: 'DELETE', path: '/indexes/' + encodeURIComponent(index), acceptedStatuses: [404] })
+ }
+}
diff --git a/apps/medusa-be/src/modules/meilisearch/content-events.ts b/apps/medusa-be/src/modules/meilisearch/content-events.ts
new file mode 100644
index 0000000000..06aa91e1f4
--- /dev/null
+++ b/apps/medusa-be/src/modules/meilisearch/content-events.ts
@@ -0,0 +1,66 @@
+import type { Logger, MedusaContainer } from '@medusajs/framework/types'
+import { MeilisearchAdminClient } from './admin-client'
+import { buildContentSearchDocument, cleanSearchText } from './documents'
+import { isMeilisearchEnabled } from './env'
+import { loadSearchProfiles } from './profiles'
+import { CONTENT_INDEX_SETTINGS } from './settings'
+
+export type CmsSearchChange = {
+ collection: string
+ doc?: Record
+ operation?: string
+}
+
+const SEARCHABLE_COLLECTION_TYPES = {
+ articles: 'article',
+ pages: 'page'
+} as const
+
+const normalizeLocale = (value: string): string => value.trim().toLowerCase().replaceAll('_', '-').split('-')[0] ?? ''
+
+const isPublished = (change: CmsSearchChange, type: 'article' | 'page'): boolean => change.operation !== 'delete' && change.doc?.status === 'published' && (type === 'article' || change.doc?.visibility === 'public')
+
+export const reconcileContentSearchChange = async (change: CmsSearchChange, logger: Logger, container: MedusaContainer): Promise => {
+ if (!isMeilisearchEnabled()) {
+ return
+ }
+
+ const type = SEARCHABLE_COLLECTION_TYPES[change.collection as keyof typeof SEARCHABLE_COLLECTION_TYPES]
+
+ if (!type) {
+ return
+ }
+
+ const rawId = change.doc?.id
+
+ if ((typeof rawId !== 'string' || !rawId.trim()) && (typeof rawId !== 'number' || !Number.isFinite(rawId))) {
+ logger.warn('Skipping ' + change.collection + ' search projection because the document id is missing')
+
+ return
+ }
+
+ const locale = typeof change.doc?.locale === 'string' ? normalizeLocale(change.doc.locale) : undefined
+ const profiles = (await loadSearchProfiles(container)).filter((profile) => !locale || normalizeLocale(profile.locale) === locale)
+ const client = new MeilisearchAdminClient()
+ const documentId = type + '_' + String(rawId)
+
+ for (const profile of profiles) {
+ const index = profile.indexes.content
+
+ await client.ensureIndex(index)
+ await client.updateSettings(index, CONTENT_INDEX_SETTINGS as Record)
+
+ if (isPublished(change, type)) {
+ const contentSource = {
+ ...change.doc,
+ slug: typeof change.doc?.slug === 'string' ? cleanSearchText(change.doc.slug) : change.doc?.slug
+ }
+
+ const document = buildContentSearchDocument(contentSource, type, profile.locale)
+
+ await client.addDocuments(index, [document])
+ } else {
+ await client.deleteDocuments(index, [documentId])
+ }
+ }
+}
diff --git a/apps/medusa-be/src/modules/meilisearch/documents.ts b/apps/medusa-be/src/modules/meilisearch/documents.ts
new file mode 100644
index 0000000000..1209bd8cd0
--- /dev/null
+++ b/apps/medusa-be/src/modules/meilisearch/documents.ts
@@ -0,0 +1,259 @@
+import { createHash } from 'node:crypto'
+import { buildProductFacetDocument } from './facets/product-facets'
+
+type UnknownRecord = Record
+
+const PRODUCT_IDENTIFIER_METADATA_FIELDS = ['user_code', 'source_sku', 'manufacturer_code', 'external_id', 'legacy_id', 'symmy_id'] as const
+const PRODUCT_IDENTIFIER_FIELDS = ['id', 'ean', 'upc', 'barcode', 'sku'] as const
+const POPULARITY_FIELDS = ['selled', 'sold_count', 'sales_count', 'popularity'] as const
+const TRADEMARK_SYMBOLS_REGEX = /[®™]/g
+const HTML_TAG_REGEX = /<[^>]*>/g
+const MEILISEARCH_DOCUMENT_ID_REGEX = /^[a-zA-Z0-9_-]+$/
+const MEILISEARCH_DOCUMENT_ID_MAX_BYTES = 511
+
+const asRecord = (value: unknown): UnknownRecord | undefined => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return
+ }
+
+ return value as UnknownRecord
+}
+
+const asRecords = (value: unknown): UnknownRecord[] => Array.isArray(value) ? value.map((entry) => asRecord(entry)).filter((entry): entry is UnknownRecord => Boolean(entry)) : []
+
+const addString = (values: string[], value: unknown) => {
+ if (typeof value === 'string' && value.trim()) {
+ values.push(value.trim())
+ } else if (typeof value === 'number' && Number.isFinite(value)) {
+ values.push(String(value))
+ }
+}
+
+const dedupeStrings = (values: string[]): string[] => Array.from(new Set(values.filter(Boolean)))
+
+const toNumber = (value: unknown): number => {
+ if (typeof value === 'number') {
+ return value
+ }
+
+ if (typeof value === 'string') {
+ return Number.parseFloat(value)
+ }
+
+ return Number.NaN
+}
+
+export const cleanSearchText = (value: string): string => value.replaceAll(TRADEMARK_SYMBOLS_REGEX, ' ').replaceAll(/\s+/g, ' ').trim()
+
+export const normalizeSearchIdentifier = (value: string): string => cleanSearchText(value).normalize('NFKC').toLocaleLowerCase()
+
+const collectMetadataIdentifiers = (metadata: UnknownRecord | undefined, identifiers: string[]) => {
+ for (const field of PRODUCT_IDENTIFIER_METADATA_FIELDS) {
+ addString(identifiers, metadata?.[field])
+ }
+}
+
+const collectRecordSearchIdentifiers = (document: UnknownRecord): string[] => {
+ const identifiers: string[] = []
+
+ for (const field of PRODUCT_IDENTIFIER_FIELDS) {
+ addString(identifiers, document[field])
+ }
+
+ collectMetadataIdentifiers(asRecord(document.metadata), identifiers)
+
+ return identifiers
+}
+
+export const collectProductSearchIdentifiers = (document: UnknownRecord): string[] => {
+ const identifiers = collectRecordSearchIdentifiers(document)
+
+ for (const variant of asRecords(document.variants)) {
+ identifiers.push(...collectRecordSearchIdentifiers(variant))
+ }
+
+ return dedupeStrings(identifiers)
+}
+
+const collectVariantSearchIdentifiers = (document: UnknownRecord, variant: UnknownRecord): string[] => dedupeStrings([
+ ...collectRecordSearchIdentifiers(document),
+ ...collectRecordSearchIdentifiers(variant)
+])
+
+const getSearchDocumentId = (value: unknown): string => {
+ if (typeof value === 'string' || typeof value === 'number') {
+ return String(value)
+ }
+
+ return ''
+}
+
+const readPopularity = (document: UnknownRecord): number => {
+ const metadata = asRecord(document.metadata)
+
+ for (const field of POPULARITY_FIELDS) {
+ const value = metadata?.[field] ?? document[field]
+ const parsed = toNumber(value)
+
+ if (Number.isFinite(parsed)) {
+ return Math.max(0, parsed)
+ }
+ }
+
+ return 0
+}
+
+const cleanValue = (value: unknown): unknown => {
+ if (typeof value === 'string') {
+ return cleanSearchText(value)
+ }
+
+ if (Array.isArray(value)) {
+ const entries = value
+ .map((entry) => cleanValue(entry))
+ .filter((entry) => entry !== undefined && entry !== null && entry !== '' && (!Array.isArray(entry) || entry.length > 0))
+
+ return entries.length > 0 ? entries : undefined
+ }
+
+ const record = asRecord(value)
+
+ if (record) {
+ const entries = Object.entries(record)
+ .map(([key, entry]) => [key, cleanValue(entry)] as const)
+ .filter(([, entry]) => entry !== undefined && entry !== null && entry !== '')
+
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined
+ }
+
+ return value ?? undefined
+}
+
+export const cleanSearchDocument = (document: DocumentType): DocumentType => (cleanValue(document) ?? {}) as DocumentType
+
+export const buildProductSearchDocument = (
+ document: UnknownRecord,
+
+ options?: {
+ popularity?: number
+ }
+): UnknownRecord => {
+ const variants = asRecords(document.variants)
+ const identifiers = collectProductSearchIdentifiers(document)
+
+ const searchDocument = {
+ ...document,
+ ...buildProductFacetDocument(document),
+ facet_popularity: options?.popularity === undefined ? readPopularity(document) : Math.max(0, options.popularity),
+ search_identifiers: identifiers,
+ search_identifiers_normalized: identifiers.map(normalizeSearchIdentifier),
+ search_has_variants: variants.length > 0,
+ search_product_id: document.id,
+ search_result_kind: 'product',
+ search_variant_titles: variants.map((variant) => variant.title).filter((title): title is string => typeof title === 'string' && Boolean(title.trim()))
+ }
+
+ return cleanSearchDocument(searchDocument)
+}
+
+const buildVariantDocumentId = (productId: unknown, variantId: unknown): string => {
+ const raw = 'variant_' + getSearchDocumentId(productId) + '_' + getSearchDocumentId(variantId)
+
+ if (MEILISEARCH_DOCUMENT_ID_REGEX.test(raw) && Buffer.byteLength(raw) <= MEILISEARCH_DOCUMENT_ID_MAX_BYTES) {
+ return raw
+ }
+
+ const digest = createHash('sha256').update(raw).digest('hex').slice(0, 16)
+
+ return 'variant_' + digest
+}
+
+const buildProductVariantSearchDocument = (document: UnknownRecord, variant: UnknownRecord, popularity: number | undefined): UnknownRecord => {
+ const identifiers = collectVariantSearchIdentifiers(document, variant)
+
+ return cleanSearchDocument({
+ ...document,
+ ...buildProductFacetDocument({ ...document, variants: [variant] }),
+ id: buildVariantDocumentId(document.id, variant.id),
+ facet_popularity: popularity === undefined ? readPopularity(document) : Math.max(0, popularity),
+ search_identifiers: identifiers,
+ search_identifiers_normalized: identifiers.map(normalizeSearchIdentifier),
+ search_has_variants: true,
+ search_product_id: document.id,
+ search_result_kind: 'variant',
+ search_variant_id: variant.id,
+ search_variant_title: variant.title,
+ search_variant_titles: typeof variant.title === 'string' ? [variant.title] : [],
+ variants: [variant]
+ })
+}
+
+export const buildProductSearchDocuments = (document: UnknownRecord, options?: { popularity?: number }): UnknownRecord[] => {
+ const variants = asRecords(document.variants)
+
+ return [
+ buildProductSearchDocument(document, options),
+ ...variants.filter((variant) => Boolean(getSearchDocumentId(variant.id))).map((variant) => buildProductVariantSearchDocument(document, variant, options?.popularity))
+ ]
+}
+
+export const buildCategorySearchDocument = (document: UnknownRecord): UnknownRecord =>
+ cleanSearchDocument({
+ id: document.id,
+ name: document.name,
+ description: document.description,
+ handle: document.handle,
+ parent_category_id: document.parent_category_id ?? asRecord(document.parent_category)?.id
+ })
+
+export const buildBrandSearchDocument = (document: UnknownRecord): UnknownRecord => cleanSearchDocument({ id: document.id, title: document.title, description: document.description, handle: document.handle })
+
+const extractContentText = (value: unknown): string => {
+ if (typeof value === 'string') {
+ return cleanSearchText(value.replaceAll(HTML_TAG_REGEX, ' '))
+ }
+
+ if (Array.isArray(value)) {
+ return cleanSearchText(value.map(extractContentText).join(' '))
+ }
+
+ const record = asRecord(value)
+
+ if (!record) {
+ return ''
+ }
+
+ return cleanSearchText(Object.values(record).map(extractContentText).join(' '))
+}
+
+const buildContentDocumentId = (type: 'article' | 'page', sourceId: unknown): string => {
+ const raw = String(sourceId)
+ const direct = type + '_' + raw
+
+ if (MEILISEARCH_DOCUMENT_ID_REGEX.test(direct) && Buffer.byteLength(direct) <= MEILISEARCH_DOCUMENT_ID_MAX_BYTES) {
+ return direct
+ }
+
+ const digest = createHash('sha256').update(raw).digest('hex').slice(0, 16)
+ const readable = raw.replaceAll(/[^a-zA-Z0-9_-]/g, '_') || 'id'
+ const readableBudget = MEILISEARCH_DOCUMENT_ID_MAX_BYTES - Buffer.byteLength(type) - Buffer.byteLength(digest) - 2
+
+ return type + '_' + readable.slice(0, readableBudget) + '_' + digest
+}
+
+export const buildContentSearchDocument = (document: UnknownRecord, type: 'article' | 'page', locale: string): UnknownRecord => {
+ const slug = typeof document.slug === 'string' ? document.slug : ''
+ const href = type === 'article' ? '/blog/' + slug : '/' + slug
+
+ return cleanSearchDocument({
+ id: buildContentDocumentId(type, document.id),
+ source_id: String(document.id),
+ type: type,
+ locale: locale,
+ title: document.title,
+ excerpt: document.excerpt,
+ content: extractContentText(document.contentHTML ?? document.content ?? document.excerpt),
+ slug: slug,
+ href: href
+ })
+}
diff --git a/apps/medusa-be/src/modules/meilisearch/medusa-config.ts b/apps/medusa-be/src/modules/meilisearch/medusa-config.ts
index 641852b67a..c8ee953072 100644
--- a/apps/medusa-be/src/modules/meilisearch/medusa-config.ts
+++ b/apps/medusa-be/src/modules/meilisearch/medusa-config.ts
@@ -1,17 +1,15 @@
import type { MedusaConfigEnv } from "../../config/env"
import type { MedusaPluginConfig } from "../../config/types"
-import { buildProductFacetDocument } from "./facets/product-facets"
-
-const MEILISEARCH_TYPO_TOLERANCE_SETTINGS = {
- enabled: true,
- minWordSizeForTypos: {
- oneTypo: 4,
- twoTypos: 10,
- },
- disableOnWords: [],
- disableOnAttributes: [],
- disableOnNumbers: false,
-}
+import {
+ buildBrandSearchDocument,
+ buildCategorySearchDocument,
+ buildProductSearchDocument,
+} from "./documents"
+import {
+ BRAND_INDEX_SETTINGS,
+ CATEGORY_INDEX_SETTINGS,
+ PRODUCT_INDEX_SETTINGS,
+} from "./settings"
export function buildMeilisearchPlugin(
env: MedusaConfigEnv
@@ -45,64 +43,14 @@ export function buildMeilisearchPlugin(
"sales_channels.id",
"variants.id",
"variants.sku",
+ "variants.ean",
+ "variants.upc",
+ "variants.barcode",
+ "variants.metadata",
"variants.prices.amount",
"variants.prices.currency_code",
],
- indexSettings: {
- searchableAttributes: [
- "title",
- "description",
- "handle",
- "brand.title",
- "categories.name",
- "variants.sku",
- ],
- displayedAttributes: [
- "id",
- "status",
- "title",
- "description",
- "thumbnail",
- "handle",
- "created_at",
- "metadata",
- "brand",
- "categories",
- "sales_channels",
- "facet_product_status",
- "facet_sales_channel_ids",
- "facet_status",
- "facet_form",
- "facet_brand",
- "facet_ingredient",
- "facet_category_ids",
- "facet_in_stock",
- "facet_price",
- ],
- filterableAttributes: [
- "id",
- "handle",
- "facet_product_status",
- "facet_sales_channel_ids",
- "facet_status",
- "facet_form",
- "facet_brand",
- "facet_ingredient",
- "facet_category_ids",
- "facet_in_stock",
- "facet_price",
- ],
- sortableAttributes: ["created_at", "title", "facet_price"],
- typoTolerance: MEILISEARCH_TYPO_TOLERANCE_SETTINGS,
- rankingRules: [
- "sort",
- "words",
- "typo",
- "proximity",
- "attribute",
- "exactness",
- ],
- },
+ indexSettings: PRODUCT_INDEX_SETTINGS,
transformer: async (
document: Record,
defaultTransformer: (
@@ -111,35 +59,30 @@ export function buildMeilisearchPlugin(
) => {
const transformedDocument = defaultTransformer(document)
- return {
- ...transformedDocument,
- ...buildProductFacetDocument(transformedDocument),
- }
+ return buildProductSearchDocument(transformedDocument)
},
primaryKey: "id",
},
categories: {
type: "categories",
enabled: true,
- fields: ["id", "description", "handle"],
- indexSettings: {
- searchableAttributes: ["description"],
- displayedAttributes: ["id", "description", "handle"],
- filterableAttributes: ["id", "handle", "description"],
- typoTolerance: MEILISEARCH_TYPO_TOLERANCE_SETTINGS,
- },
+ fields: ["id", "name", "description", "handle", "parent_category_id"],
+ indexSettings: CATEGORY_INDEX_SETTINGS,
+ transformer: (
+ document: Record,
+ defaultTransformer: (
+ input: Record
+ ) => Record
+ ) => buildCategorySearchDocument(defaultTransformer(document)),
primaryKey: "id",
},
brands: {
type: "brands",
enabled: true,
- fields: ["id", "title", "handle"],
- indexSettings: {
- searchableAttributes: ["title", "handle"],
- displayedAttributes: ["id", "title", "handle"],
- filterableAttributes: ["id", "title", "handle"],
- typoTolerance: MEILISEARCH_TYPO_TOLERANCE_SETTINGS,
- },
+ fields: ["id", "title", "description", "handle"],
+ indexSettings: BRAND_INDEX_SETTINGS,
+ transformer: (document: Record) =>
+ buildBrandSearchDocument(document),
primaryKey: "id",
},
},
diff --git a/apps/medusa-be/src/modules/meilisearch/profiles.ts b/apps/medusa-be/src/modules/meilisearch/profiles.ts
new file mode 100644
index 0000000000..7747f6e8d7
--- /dev/null
+++ b/apps/medusa-be/src/modules/meilisearch/profiles.ts
@@ -0,0 +1,371 @@
+import type { MedusaContainer } from '@medusajs/framework/types'
+import { SEARCH_PROFILE_MODULE, type SearchProfileDTO, type SearchProfileModuleService } from '../search-profile'
+
+export const SEARCH_INDEX_TYPES = ['product', 'category', 'brand', 'content'] as const
+
+export type SearchIndexType = (typeof SEARCH_INDEX_TYPES)[number]
+
+export type SearchProfileLimits = {
+ autocomplete: Record
+ fullSearch: number
+ page: number
+ popular: number
+}
+
+export type SearchProfile = {
+ availability: 'all' | 'in-stock'
+ domain: string
+ id?: string
+ indexes: Record
+ key: string
+ limits: SearchProfileLimits
+ locale: string
+ minimumRankingScore: number
+ salesChannelIds: string[]
+ separateVariantResults: boolean
+ shop: string
+ strict: boolean
+}
+
+type RawSearchProfile = {
+ availability?: unknown
+ domain?: unknown
+ key?: unknown
+ limits?: unknown
+ locale?: unknown
+ minimumRankingScore?: unknown
+ salesChannelIds?: unknown
+ separateVariantResults?: unknown
+ shop?: unknown
+ strict?: unknown
+}
+
+export type SearchProfileSelection = {
+ locale?: string
+ requestedKey?: string
+ salesChannelIds?: string[]
+}
+
+export class SearchProfileConfigurationError extends Error {}
+
+export class SearchProfileResolutionError extends Error {}
+
+const DEFAULT_AUTOCOMPLETE_LIMITS: Record = {
+ product: 6,
+ category: 3,
+ brand: 3,
+ content: 3
+}
+
+const DEFAULT_PROFILE: SearchProfile = {
+ availability: 'all',
+ domain: 'default',
+
+ indexes: {
+ product: 'product_default_default_default',
+ category: 'category_default_default_default',
+ brand: 'brand_default_default_default',
+ content: 'content_default_default_default'
+ },
+
+ key: 'default',
+
+ limits: {
+ autocomplete: DEFAULT_AUTOCOMPLETE_LIMITS,
+ fullSearch: 500,
+ page: 100,
+ popular: 12
+ },
+
+ locale: 'default',
+ minimumRankingScore: 0,
+ salesChannelIds: [],
+ separateVariantResults: false,
+ shop: 'default',
+ strict: false
+}
+
+const normalizeSegment = (value: string, field: string): string => {
+ const normalized = value
+ .trim()
+ .toLowerCase()
+ .normalize('NFKD')
+ .replaceAll(/\p{Diacritic}/gu, '')
+ .replaceAll(/[^a-z0-9_-]+/g, '-')
+ .replaceAll(/-+/g, '-')
+ .replaceAll(/^-|-$/g, '')
+
+ if (!normalized) {
+ throw new SearchProfileConfigurationError('Meilisearch search profile ' + field + ' must contain an index-safe value')
+ }
+
+ return normalized
+}
+
+const readRequiredString = (value: unknown, field: string, profileIndex: number): string => {
+ if (typeof value !== 'string' || !value.trim()) {
+ throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + profileIndex + '].' + field + ' must be a non-empty string')
+ }
+
+ return value.trim()
+}
+
+const readBoolean = (value: unknown, fallback: boolean, field: string, profileIndex: number): boolean => {
+ if (value === undefined) {
+ return fallback
+ }
+
+ if (typeof value !== 'boolean') {
+ throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + profileIndex + '].' + field + ' must be a boolean')
+ }
+
+ return value
+}
+
+const readLimitedInteger = (options: { fallback: number; field: string; maximum: number; profileIndex: number; value: unknown }): number => {
+ if (options.value === undefined) {
+ return options.fallback
+ }
+
+ if (typeof options.value !== 'number' || !Number.isInteger(options.value) || options.value < 1 || options.value > options.maximum) {
+ throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + options.profileIndex + '].' + options.field + ' must be an integer between 1 and ' + options.maximum)
+ }
+
+ return options.value
+}
+
+const readRankingScore = (value: unknown, strict: boolean, profileIndex: number): number => {
+ if (value === undefined) {
+ return strict ? 0.98 : 0.55
+ }
+
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
+ throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + profileIndex + '].minimumRankingScore must be between 0 and 1')
+ }
+
+ return value
+}
+
+const readAvailability = (value: unknown, profileIndex: number): SearchProfile['availability'] => {
+ if (value === undefined) {
+ return 'all'
+ }
+
+ if (value === 'all' || value === 'in-stock') {
+ return value
+ }
+
+ throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + profileIndex + '].availability must be "all" or "in-stock"')
+}
+
+const readStringArray = (value: unknown, field: string, profileIndex: number): string[] => {
+ if (value === undefined) {
+ return []
+ }
+
+ if (!Array.isArray(value)) {
+ throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + profileIndex + '].' + field + ' must be an array')
+ }
+
+ return Array.from(
+ new Set(
+ value.map((entry) => {
+ if (typeof entry !== 'string' || !entry.trim()) {
+ throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + profileIndex + '].' + field + ' must contain only non-empty strings')
+ }
+
+ return entry.trim()
+ })
+ )
+ )
+}
+
+const createIndexNames = (shop: string, domain: string, locale: string): Record => Object.fromEntries(SEARCH_INDEX_TYPES.map((type) => [type, type + '_' + shop + '_' + domain + '_' + locale])) as Record
+
+const readLimits = (value: unknown, profileIndex: number): SearchProfileLimits => {
+ const raw = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}
+ const rawAutocomplete = raw.autocomplete && typeof raw.autocomplete === 'object' && !Array.isArray(raw.autocomplete) ? (raw.autocomplete as Record) : {}
+
+ return { autocomplete: Object.fromEntries(SEARCH_INDEX_TYPES.map((type) => [ type, readLimitedInteger({ value: rawAutocomplete[type], fallback: DEFAULT_AUTOCOMPLETE_LIMITS[type], field: 'limits.autocomplete.' + type, profileIndex: profileIndex, maximum: 24 }) ])) as Record, fullSearch: readLimitedInteger({ value: raw.fullSearch, fallback: 500, field: 'limits.fullSearch', profileIndex: profileIndex, maximum: 1000 }), page: readLimitedInteger({ value: raw.page, fallback: 100, field: 'limits.page', profileIndex: profileIndex, maximum: 100 }), popular: readLimitedInteger({ value: raw.popular, fallback: 12, field: 'limits.popular', profileIndex: profileIndex, maximum: 48 }) }
+}
+
+const parseProfile = (rawProfile: RawSearchProfile, profileIndex: number): SearchProfile => {
+ const shop = normalizeSegment(readRequiredString(rawProfile.shop, 'shop', profileIndex), 'shop')
+ const domain = normalizeSegment(readRequiredString(rawProfile.domain, 'domain', profileIndex), 'domain')
+ const locale = normalizeSegment(readRequiredString(rawProfile.locale, 'locale', profileIndex), 'locale')
+ const strict = readBoolean(rawProfile.strict, false, 'strict', profileIndex)
+ const key = normalizeSegment(typeof rawProfile.key === 'string' && rawProfile.key.trim() ? rawProfile.key : shop + '-' + domain + '-' + locale, 'key')
+
+ return {
+ availability: readAvailability(rawProfile.availability, profileIndex),
+ domain: domain,
+ indexes: createIndexNames(shop, domain, locale),
+ key: key,
+ limits: readLimits(rawProfile.limits, profileIndex),
+ locale: locale,
+ minimumRankingScore: readRankingScore(rawProfile.minimumRankingScore, strict, profileIndex),
+ salesChannelIds: readStringArray(rawProfile.salesChannelIds, 'salesChannelIds', profileIndex),
+ separateVariantResults: readBoolean(rawProfile.separateVariantResults, false, 'separateVariantResults', profileIndex),
+ shop: shop,
+ strict: strict
+ }
+}
+
+const parseProfilesJson = (value: string): unknown[] => {
+ let parsed: unknown
+
+ try {
+ parsed = JSON.parse(value)
+ } catch (error) {
+ throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES must be valid JSON: ' + (error instanceof Error ? error.message : String(error)))
+ }
+
+ if (!Array.isArray(parsed) || parsed.length === 0) {
+ throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES must be a non-empty JSON array')
+ }
+
+ return parsed
+}
+
+const parseProfileEntry = (entry: unknown, index: number): SearchProfile => {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ throw new SearchProfileConfigurationError('MEILISEARCH_SEARCH_PROFILES[' + index + '] must be an object')
+ }
+
+ return parseProfile(entry as RawSearchProfile, index)
+}
+
+export const parseSearchProfileConfiguration = (entry: unknown, index = 0): SearchProfile => parseProfileEntry(entry, index)
+
+const assertUniqueProfileValues = (profiles: SearchProfile[], describe: (profile: SearchProfile) => string, label: string) => {
+ const seen = new Set()
+
+ for (const profile of profiles) {
+ const value = describe(profile)
+
+ if (seen.has(value)) {
+ throw new SearchProfileConfigurationError('Duplicate Meilisearch ' + label + ': ' + value)
+ }
+
+ seen.add(value)
+ }
+}
+
+export const validateSearchProfileSet = (profiles: SearchProfile[]) => {
+ assertUniqueProfileValues(profiles, (profile) => profile.key, 'profile key')
+ assertUniqueProfileValues(profiles, (profile) => profile.shop + ':' + profile.domain + ':' + profile.locale, 'shop/domain/locale profile')
+
+ if (profiles.length > 1 && profiles.some((profile) => profile.salesChannelIds.length === 0)) {
+ throw new SearchProfileConfigurationError('Every Meilisearch search profile must declare salesChannelIds when more than one domain profile is configured')
+ }
+}
+
+export const readSearchProfiles = (env: NodeJS.ProcessEnv = process.env): SearchProfile[] => {
+ const value = env.MEILISEARCH_SEARCH_PROFILES?.trim()
+
+ if (!value) {
+ return [DEFAULT_PROFILE]
+ }
+
+ const profiles = parseProfilesJson(value).map(parseProfileEntry)
+
+ validateSearchProfileSet(profiles)
+
+ return profiles
+}
+
+export const persistedSearchProfileToRuntime = (profile: SearchProfileDTO, profileIndex = 0): SearchProfile => ({
+ ...parseProfile(
+ {
+ availability: profile.availability,
+ domain: profile.domain,
+ key: profile.key,
+
+ limits: {
+ autocomplete: {
+ product: profile.autocomplete_product_limit,
+ category: profile.autocomplete_category_limit,
+ brand: profile.autocomplete_brand_limit,
+ content: profile.autocomplete_content_limit
+ },
+
+ fullSearch: profile.full_search_limit,
+ page: profile.max_results_per_page,
+ popular: profile.popular_limit
+ },
+
+ locale: profile.locale,
+ minimumRankingScore: profile.minimum_ranking_score ?? undefined,
+ salesChannelIds: profile.sales_channel_ids,
+ separateVariantResults: profile.separate_variant_results,
+ shop: profile.shop,
+ strict: profile.strict
+ },
+
+ profileIndex
+ ),
+
+ id: profile.id
+})
+
+export const loadSearchProfiles = async (container: MedusaContainer): Promise => {
+ let service: SearchProfileModuleService
+
+ try {
+ service = container.resolve(SEARCH_PROFILE_MODULE)
+ } catch {
+ return []
+ }
+
+ if (typeof service.listConfiguredProfiles !== 'function') {
+ return []
+ }
+
+ const persisted = typeof service.listRuntimeProfiles === 'function' ? await service.listRuntimeProfiles() : await service.listConfiguredProfiles({ enabledOnly: true })
+
+ if (persisted.length === 0) {
+ return []
+ }
+
+ const profiles = persisted.map(persistedSearchProfileToRuntime)
+
+ validateSearchProfileSet(profiles)
+
+ return profiles
+}
+
+const normalizeLocale = (value: string): string => value.trim().toLowerCase().replaceAll('_', '-').split('-')[0] ?? ''
+
+const hasSalesChannelAccess = (profile: SearchProfile, salesChannelIds: string[]): boolean => profile.salesChannelIds.some((id) => salesChannelIds.includes(id))
+
+const sortProfilesByKey = (profiles: SearchProfile[]): SearchProfile[] => [...profiles].sort((left, right) => left.key.localeCompare(right.key))
+
+export const resolveSearchProfile = (selection: SearchProfileSelection, profiles: SearchProfile[] = []): SearchProfile => {
+ const salesChannelIds = selection.salesChannelIds ?? []
+ const requestedKey = selection.requestedKey?.trim().toLowerCase()
+
+ if (requestedKey) {
+ const profile = profiles.find((entry) => entry.key === requestedKey)
+
+ if (!profile) {
+ throw new SearchProfileResolutionError('Unknown Meilisearch search profile: ' + requestedKey)
+ }
+
+ if (!hasSalesChannelAccess(profile, salesChannelIds)) {
+ throw new SearchProfileResolutionError('Search profile ' + requestedKey + ' is not available for this storefront')
+ }
+
+ return profile
+ }
+
+ const requestedLocale = selection.locale ? normalizeLocale(selection.locale) : undefined
+ const accessible = profiles.filter((profile) => hasSalesChannelAccess(profile, salesChannelIds))
+ const localized = requestedLocale ? accessible.filter((profile) => normalizeLocale(profile.locale) === requestedLocale) : accessible
+ const candidates = requestedLocale ? localized : accessible
+
+ if (candidates.length > 0) {
+ return sortProfilesByKey(candidates)[0] as SearchProfile
+ }
+
+ throw new SearchProfileResolutionError('No Meilisearch profile is assigned to this storefront Sales Channel and language')
+}
diff --git a/apps/medusa-be/src/modules/meilisearch/search-results.ts b/apps/medusa-be/src/modules/meilisearch/search-results.ts
new file mode 100644
index 0000000000..793f8b702d
--- /dev/null
+++ b/apps/medusa-be/src/modules/meilisearch/search-results.ts
@@ -0,0 +1,201 @@
+import { cleanSearchText, normalizeSearchIdentifier } from './documents'
+
+export type RankedProductHit = {
+ _rankingScore?: number
+ brand?: unknown
+ id?: string | number
+ search_product_id?: unknown
+ search_variant_id?: unknown
+ search_identifiers_normalized?: unknown
+ search_variant_title?: unknown
+ search_variant_titles?: unknown
+ title?: unknown
+}
+
+export type RankedProductMatch = {
+ productId: string
+ variantId?: string
+}
+
+const getStringId = (value: unknown): string | undefined => {
+ if (typeof value === 'string' && value.trim()) {
+ return value
+ }
+
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return String(value)
+ }
+
+ return
+}
+
+const getProductMatch = (hit: RankedProductHit): RankedProductMatch | undefined => {
+ const productId = getStringId(hit.search_product_id) ?? getStringId(hit.id)
+
+ if (!productId) {
+ return
+ }
+
+ const variantId = getStringId(hit.search_variant_id)
+
+ return variantId ? { productId: productId, variantId: variantId } : { productId: productId }
+}
+
+const hasExactIdentifier = (hit: RankedProductHit, normalizedQuery: string): boolean => Array.isArray(hit.search_identifiers_normalized) && hit.search_identifiers_normalized.some((value) => typeof value === 'string' && value === normalizedQuery)
+
+const getSearchTokens = (value: string): string[] => cleanSearchText(value).normalize('NFKD').replaceAll(/\p{Diacritic}/gu, '').toLocaleLowerCase().split(/[^\p{Letter}\p{Number}]+/u).filter(Boolean)
+
+const getNormalizedSearchIdentifiers = (hit: RankedProductHit): string[] => {
+ if (!Array.isArray(hit.search_identifiers_normalized)) {
+ return []
+ }
+
+ return hit.search_identifiers_normalized.filter((value): value is string => typeof value === 'string' && Boolean(value))
+}
+
+const hasPartialIdentifierMatch = (hit: RankedProductHit, normalizedQuery: string): boolean => Boolean(normalizedQuery) && getNormalizedSearchIdentifiers(hit).some((identifier) => identifier !== normalizedQuery && identifier.includes(normalizedQuery))
+
+const isIdentifierLikeQuery = (query: string): boolean => {
+ const normalizedQuery = normalizeSearchIdentifier(query)
+
+ return /^[\p{Letter}\p{Number}_-]+$/u.test(normalizedQuery) && /[\p{Number}_-]/u.test(normalizedQuery)
+}
+
+const getBrandTitles = (value: unknown): unknown[] => {
+ if (Array.isArray(value)) {
+ return value.flatMap(getBrandTitles)
+ }
+
+ if (value && typeof value === 'object') {
+ return [(value as { title?: unknown }).title]
+ }
+
+ return []
+}
+
+const hasSearchTextPrefix = (hit: RankedProductHit, query: string, includeBrand: boolean): boolean => {
+ const queryTokens = getSearchTokens(query)
+ const values = [hit.title, hit.search_variant_title, ...(Array.isArray(hit.search_variant_titles) ? hit.search_variant_titles : []), ...(includeBrand ? getBrandTitles(hit.brand) : [])]
+ const searchableTokens = values.flatMap((value) => typeof value === 'string' ? getSearchTokens(value) : [])
+
+ return queryTokens.length > 0 && queryTokens.every((queryToken) => searchableTokens.some((searchableToken) => searchableToken.startsWith(queryToken)))
+}
+
+export const isAcceptedProductHit = (rawHit: unknown, query: string, minimumRankingScore: number, strict: boolean): boolean => {
+ if (!rawHit || typeof rawHit !== 'object' || Array.isArray(rawHit)) {
+ return false
+ }
+
+ const hit = rawHit as RankedProductHit
+ const normalizedQuery = normalizeSearchIdentifier(cleanSearchText(query))
+
+ if (hasExactIdentifier(hit, normalizedQuery)) {
+ return true
+ }
+
+ if (hasSearchTextPrefix(hit, query, !strict)) {
+ return true
+ }
+
+ if (hasPartialIdentifierMatch(hit, normalizedQuery)) {
+ return false
+ }
+
+ if (isIdentifierLikeQuery(query)) {
+ return false
+ }
+
+ return typeof hit._rankingScore !== 'number' || hit._rankingScore >= minimumRankingScore
+}
+
+export const selectRankedProductIds = (
+ rawHits: unknown,
+ query: string,
+ minimumRankingScore: number,
+ strict: boolean
+): {
+ exactIdentifierMatch: boolean
+ ids: string[]
+ matches: RankedProductMatch[]
+ selectedHits: RankedProductHit[]
+} => {
+ const hits = Array.isArray(rawHits) ? rawHits.filter((hit): hit is RankedProductHit => Boolean(hit && typeof hit === 'object' && !Array.isArray(hit))) : []
+ const cleanedQuery = cleanSearchText(query)
+ const normalizedQuery = normalizeSearchIdentifier(cleanedQuery)
+ const scoreFiltered = cleanedQuery ? hits.filter((hit) => isAcceptedProductHit(hit, cleanedQuery, minimumRankingScore, strict)) : hits
+ const exactHits = normalizedQuery ? hits.filter((hit) => hasExactIdentifier(hit, normalizedQuery)) : []
+ const selectedHits = exactHits.length > 0 ? exactHits : scoreFiltered
+ const matches = selectedHits.map(getProductMatch).filter((match): match is RankedProductMatch => match !== undefined)
+
+ return { exactIdentifierMatch: exactHits.length > 0, ids: matches.map((match) => match.productId), matches: matches, selectedHits: selectedHits }
+}
+
+export const buildProductResultFilter = (separateVariantResults: boolean, query: string): string => separateVariantResults && Boolean(cleanSearchText(query)) ? '(search_result_kind = "variant" OR (search_result_kind = "product" AND search_has_variants = false))' : 'search_result_kind = "product"'
+
+const getProductRecordId = (product: Record): string | undefined => getStringId(product.id)
+
+const getVariantTitle = (variant: Record, variantId: string): string => {
+ for (const field of ['title', 'sku', 'ean', 'upc', 'barcode']) {
+ const value = variant[field]
+
+ if (typeof value === 'string' && value.trim()) {
+ return value.trim()
+ }
+ }
+
+ return variantId
+}
+
+export const expandProductsBySearchMatches = (products: Record[], matches: RankedProductMatch[]): Record[] => {
+ const productsById = new Map(products.map((product) => [getProductRecordId(product), product]))
+
+ return matches.flatMap((match) => {
+ const product = productsById.get(match.productId)
+
+ if (!product) {
+ return []
+ }
+
+ if (!match.variantId || !Array.isArray(product.variants)) {
+ return [product]
+ }
+
+ const variants = product.variants.filter((variant): variant is Record => Boolean(variant && typeof variant === 'object' && !Array.isArray(variant)))
+ const matchedVariant = variants.find((variant) => getStringId(variant.id) === match.variantId)
+
+ if (!matchedVariant) {
+ return [product]
+ }
+
+ return [{
+ ...product,
+
+ search_result: {
+ variant_id: match.variantId,
+ variant_title: getVariantTitle(matchedVariant, match.variantId)
+ },
+
+ variants: [matchedVariant, ...variants.filter((variant) => getStringId(variant.id) !== match.variantId)]
+ }]
+ })
+}
+
+export const getSalesChannelIds = (value: unknown): string[] => {
+ if (Array.isArray(value)) {
+ return value.filter((item): item is string => typeof item === 'string')
+ }
+
+ if (typeof value === 'string') {
+ return [value]
+ }
+
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ const inValue = (value as Record).$in
+
+ if (Array.isArray(inValue)) {
+ return inValue.filter((item): item is string => typeof item === 'string')
+ }
+ }
+
+ return []
+}
diff --git a/apps/medusa-be/src/modules/meilisearch/settings.ts b/apps/medusa-be/src/modules/meilisearch/settings.ts
new file mode 100644
index 0000000000..7b9553c064
--- /dev/null
+++ b/apps/medusa-be/src/modules/meilisearch/settings.ts
@@ -0,0 +1,106 @@
+const TYPO_TOLERANCE_SETTINGS = {
+ enabled: true,
+
+ minWordSizeForTypos: {
+ oneTypo: 4,
+ twoTypos: 6
+ },
+
+ disableOnWords: [],
+ disableOnAttributes: ['search_identifiers', 'search_identifiers_normalized'],
+ disableOnNumbers: true
+}
+
+const COMMON_SETTINGS = {
+ typoTolerance: TYPO_TOLERANCE_SETTINGS,
+
+ pagination: {
+ maxTotalHits: 500
+ },
+
+ proximityPrecision: 'byAttribute'
+}
+
+export const PRODUCT_INDEX_SETTINGS = {
+ ...COMMON_SETTINGS,
+ searchableAttributes: ['search_identifiers', 'title', 'search_variant_titles', 'brand.title', 'categories.name', 'description', 'handle'],
+
+ displayedAttributes: [
+ 'id',
+ 'status',
+ 'title',
+ 'description',
+ 'thumbnail',
+ 'handle',
+ 'created_at',
+ 'metadata',
+ 'brand',
+ 'categories',
+ 'sales_channels',
+ 'search_identifiers_normalized',
+ 'search_has_variants',
+ 'search_product_id',
+ 'search_result_kind',
+ 'search_variant_id',
+ 'search_variant_title',
+ 'search_variant_titles',
+ 'facet_product_status',
+ 'facet_sales_channel_ids',
+ 'facet_status',
+ 'facet_form',
+ 'facet_brand',
+ 'facet_ingredient',
+ 'facet_category_ids',
+ 'facet_in_stock',
+ 'facet_price',
+ 'facet_popularity'
+ ],
+
+ filterableAttributes: [
+ 'id',
+ 'handle',
+ 'search_identifiers_normalized',
+ 'search_has_variants',
+ 'search_result_kind',
+ 'facet_product_status',
+ 'facet_sales_channel_ids',
+ 'facet_status',
+ 'facet_form',
+ 'facet_brand',
+ 'facet_ingredient',
+ 'facet_category_ids',
+ 'facet_in_stock',
+ 'facet_price'
+ ],
+
+ sortableAttributes: ['created_at', 'title', 'facet_price', 'facet_popularity'],
+ rankingRules: ['words', 'typo', 'proximity', 'attributeRank', 'sort', 'wordPosition', 'exactness']
+}
+
+export const CATEGORY_INDEX_SETTINGS = {
+ ...COMMON_SETTINGS,
+ searchableAttributes: ['name', 'description', 'product_titles', 'handle'],
+ displayedAttributes: ['id', 'name', 'description', 'handle', 'parent_category_id'],
+ filterableAttributes: ['id', 'handle', 'parent_category_id']
+}
+
+export const BRAND_INDEX_SETTINGS = {
+ ...COMMON_SETTINGS,
+ searchableAttributes: ['title', 'description', 'handle'],
+ displayedAttributes: ['id', 'title', 'description', 'handle'],
+ filterableAttributes: ['id', 'handle']
+}
+
+export const CONTENT_INDEX_SETTINGS = {
+ ...COMMON_SETTINGS,
+ searchableAttributes: ['title', 'excerpt', 'content', 'slug'],
+ displayedAttributes: ['id', 'source_id', 'type', 'locale', 'title', 'excerpt', 'slug', 'href'],
+ filterableAttributes: ['id', 'source_id', 'type', 'locale', 'slug']
+}
+
+export const SEARCH_INDEX_SETTINGS = {
+ product: PRODUCT_INDEX_SETTINGS,
+ category: CATEGORY_INDEX_SETTINGS,
+ brand: BRAND_INDEX_SETTINGS,
+ content: CONTENT_INDEX_SETTINGS
+} as const
diff --git a/apps/medusa-be/src/modules/meilisearch/synchronize.ts b/apps/medusa-be/src/modules/meilisearch/synchronize.ts
new file mode 100644
index 0000000000..30403c2a9e
--- /dev/null
+++ b/apps/medusa-be/src/modules/meilisearch/synchronize.ts
@@ -0,0 +1,671 @@
+import type { ILockingModule, Logger, MedusaContainer, Query } from '@medusajs/framework/types'
+import { ContainerRegistrationKeys, Modules } from '@medusajs/framework/utils'
+import { PAYLOAD_MODULE } from '../payload'
+import type PayloadModuleService from '../payload/service'
+import { SEARCH_PROFILE_MODULE } from '../search-profile'
+import { MeilisearchAdminClient } from './admin-client'
+import { buildBrandSearchDocument, buildCategorySearchDocument, buildContentSearchDocument, buildProductSearchDocuments } from './documents'
+import { isMeilisearchEnabled } from './env'
+import { loadSearchProfiles, SEARCH_INDEX_TYPES, type SearchIndexType, type SearchProfile } from './profiles'
+import { SEARCH_INDEX_SETTINGS } from './settings'
+
+export type SearchProfileSyncMode = 'full' | 'normal'
+
+export type SearchProfileSyncResult = {
+ deleted: number
+ indexed: number
+ mode: SearchProfileSyncMode
+ profiles: number
+}
+
+export type SearchProfileSyncOptions = {
+ profileKeys?: string[]
+}
+
+type SearchSyncTargets = Record
+
+type SearchProfileSyncStateService = {
+ updateSearchProfiles: (data: Record) => Promise
+}
+
+type DatabaseConnection = {
+ raw: (query: string, bindings?: unknown[]) => Promise
+}
+
+type ProfileReferenceIds = {
+ brandIds: Set
+ categoryIds: Set
+ categoryProductTitles: Map
+}
+
+const BATCH_SIZE = 500
+const SEARCH_SYNC_LOCK_KEY = 'meilisearch-search-profiles-sync'
+
+const PRODUCT_FIELDS = [
+ 'id',
+ 'status',
+ 'title',
+ 'description',
+ 'handle',
+ 'thumbnail',
+ 'created_at',
+ 'metadata',
+ 'categories.id',
+ 'categories.name',
+ 'categories.description',
+ 'categories.handle',
+ 'brand.id',
+ 'brand.title',
+ 'brand.description',
+ 'brand.handle',
+ 'sales_channels.id',
+ 'variants.id',
+ 'variants.sku',
+ 'variants.ean',
+ 'variants.upc',
+ 'variants.barcode',
+ 'variants.metadata',
+ 'variants.prices.amount',
+ 'variants.prices.currency_code'
+]
+
+const CATEGORY_FIELDS = ['id', 'name', 'description', 'handle', 'is_active', 'parent_category_id']
+const BRAND_FIELDS = ['id', 'title', 'description', 'handle']
+
+const asRecord = (value: unknown): Record | undefined => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return
+ }
+
+ return value as Record
+}
+
+const asRecords = (value: unknown): Record[] => Array.isArray(value) ? value.map((entry) => asRecord(entry)).filter((entry): entry is Record => entry !== undefined) : []
+
+const getId = (record: Record): string | undefined => {
+ const id = record.id
+
+ if (typeof id === 'string' && id.trim()) {
+ return id
+ }
+
+ if (typeof id === 'number' && Number.isFinite(id)) {
+ return String(id)
+ }
+
+ return
+}
+
+const getRawQueryRows = (result: unknown): Record[] => {
+ if (Array.isArray(result)) {
+ return asRecords(result[0] ?? result)
+ }
+
+ const record = asRecord(result)
+
+ return asRecords(record?.rows)
+}
+
+const toNumber = (value: unknown): number => {
+ if (typeof value === 'number') {
+ return value
+ }
+
+ if (typeof value === 'string') {
+ return Number.parseFloat(value)
+ }
+
+ return Number.NaN
+}
+
+const readProductPopularity = async (database: DatabaseConnection, profile: SearchProfile): Promise> => {
+ const salesChannelClause = profile.salesChannelIds.length > 0 ? 'and o.sales_channel_id = any(?::text[])' : ''
+
+ const result = await database.raw(
+ 'select oli.product_id, ' +
+ 'sum(oi.quantity)::float as sold_quantity ' +
+ 'from order_item oi ' +
+ 'join order_line_item oli ' +
+ 'on oli.id = oi.item_id ' +
+ 'and oli.deleted_at is null ' +
+ 'join "order" o ' +
+ 'on o.id = oi.order_id ' +
+ 'and o.version = oi.version ' +
+ 'where oi.deleted_at is null ' +
+ 'and o.deleted_at is null ' +
+ 'and o.canceled_at is null ' +
+ 'and o.is_draft_order = false ' +
+ 'and oli.product_id is not null ' +
+ salesChannelClause +
+ ' group by oli.product_id',
+
+ profile.salesChannelIds.length > 0 ? [profile.salesChannelIds] : [])
+
+ const popularity = new Map()
+
+ for (const row of getRawQueryRows(result)) {
+ const productId = row.product_id
+ const soldQuantity = toNumber(row.sold_quantity)
+
+ if (typeof productId === 'string' && Number.isFinite(soldQuantity)) {
+ popularity.set(productId, Math.max(0, soldQuantity))
+ }
+ }
+
+ return popularity
+}
+
+const productBelongsToProfile = (document: Record, profile: SearchProfile): boolean => {
+ if (profile.availability === 'in-stock' && document.facet_in_stock !== true) {
+ return false
+ }
+
+ if (profile.salesChannelIds.length === 0) {
+ return true
+ }
+
+ const productSalesChannelIds = Array.isArray(document.facet_sales_channel_ids) ? document.facet_sales_channel_ids.filter((value): value is string => typeof value === 'string') : []
+
+ return profile.salesChannelIds.some((id) => productSalesChannelIds.includes(id))
+}
+
+const collectCategoryReferences = (product: Record, references: ProfileReferenceIds) => {
+ for (const category of asRecords(product.categories)) {
+ const id = getId(category)
+
+ if (id) {
+ references.categoryIds.add(id)
+
+ const title = typeof product.title === 'string' ? product.title.trim() : undefined
+
+ if (title) {
+ const titles = references.categoryProductTitles.get(id) ?? []
+
+ if (!titles.includes(title) && titles.join(' ').length < 10000) {
+ titles.push(title)
+ references.categoryProductTitles.set(id, titles)
+ }
+ }
+ }
+ }
+}
+
+const collectBrandReferences = (product: Record, references: ProfileReferenceIds) => {
+ const brands = Array.isArray(product.brand) ? asRecords(product.brand) : [asRecord(product.brand)].filter((entry): entry is Record => entry !== undefined)
+
+ for (const brand of brands) {
+ const id = getId(brand)
+
+ if (id) {
+ references.brandIds.add(id)
+ }
+ }
+}
+
+const collectProductReferences = (product: Record, references: ProfileReferenceIds) => {
+ collectCategoryReferences(product, references)
+ collectBrandReferences(product, references)
+}
+
+const fetchGraphBatch = async (
+ query: Query,
+
+ options: {
+ entity: string
+ fields: string[]
+ filters?: Record
+ offset: number
+ }
+): Promise[]> => {
+ const { data } = await query.graph({ entity: options.entity, fields: options.fields, filters: options.filters, pagination: { take: BATCH_SIZE, skip: options.offset } })
+
+ return asRecords(data)
+}
+
+const normalizeLocale = (value: string): string => value.trim().toLowerCase().replaceAll('_', '-').split('-')[0] ?? ''
+
+const applyLocalizedTranslations = async (query: Query, records: Record[], locale: string): Promise[]> => {
+ const ids = records.map(getId).filter((id): id is string => id !== undefined)
+
+ if (ids.length === 0 || locale === 'default') {
+ return records
+ }
+
+ let data: unknown[]
+
+ try {
+ const result = await query.graph({ entity: 'translation', fields: ['reference_id', 'locale_code', 'translations'], filters: { reference_id: ids } })
+
+ data = result.data
+ } catch {
+ return records
+ }
+
+ const translationsById = new Map>()
+
+ for (const row of asRecords(data)) {
+ if (typeof row.reference_id !== 'string' || typeof row.locale_code !== 'string' || normalizeLocale(row.locale_code) !== normalizeLocale(locale)) {
+ continue
+ }
+
+ const translations = asRecord(row.translations)
+
+ if (translations) {
+ translationsById.set(row.reference_id, translations)
+ }
+ }
+
+ return records.map((record) => {
+ const id = getId(record)
+ const translations = id ? translationsById.get(id) : undefined
+
+ return translations ? { ...record, ...translations } : record
+ })
+}
+
+const deleteStaleDocuments = async (client: MeilisearchAdminClient, index: string, currentIds: Set): Promise => {
+ const staleIds = (await client.getDocumentIds(index)).filter((id) => !currentIds.has(id))
+
+ for (let offset = 0; offset < staleIds.length; offset += BATCH_SIZE) {
+ await client.deleteDocuments(index, staleIds.slice(offset, offset + BATCH_SIZE))
+ }
+
+ return staleIds.length
+}
+
+const indexProductDocuments = async (options: {
+ client: MeilisearchAdminClient
+ index: string
+ popularityByProductId: Map
+ profile: SearchProfile
+ query: Query
+}): Promise<{
+ ids: Set
+ indexed: number
+ references: ProfileReferenceIds
+}> => {
+ const { client, index, popularityByProductId, profile, query } = options
+ const ids = new Set()
+
+ const references: ProfileReferenceIds = {
+ brandIds: new Set(),
+ categoryIds: new Set(),
+ categoryProductTitles: new Map()
+ }
+
+ let offset = 0
+
+ while (true) {
+ const records = await fetchGraphBatch(query, { entity: 'product', fields: PRODUCT_FIELDS, filters: { status: 'published' }, offset: offset })
+
+ if (records.length === 0) {
+ break
+ }
+
+ const localizedRecords = await applyLocalizedTranslations(query, records, profile.locale)
+
+ const documents = localizedRecords
+ .flatMap((record) => buildProductSearchDocuments(record, { popularity: popularityByProductId.get(getId(record) ?? '') }))
+ .filter((document) => productBelongsToProfile(document, profile))
+
+ for (const document of documents) {
+ const id = getId(document)
+
+ if (id) {
+ ids.add(id)
+ }
+
+ collectProductReferences(document, references)
+ }
+
+ await client.addDocuments(index, documents)
+
+ offset += records.length
+
+ if (records.length < BATCH_SIZE) {
+ break
+ }
+ }
+
+ return { ids: ids, indexed: ids.size, references: references }
+}
+
+const indexReferencedEntities = async (
+ query: Query,
+ client: MeilisearchAdminClient,
+
+ options: {
+ entity: 'brand' | 'product_category'
+ fields: string[]
+ ids: Set
+ index: string
+ locale: string
+ transform: (document: Record) => Record
+ }
+): Promise> => {
+ const currentIds = new Set()
+ const ids = [...options.ids]
+
+ for (let offset = 0; offset < ids.length; offset += BATCH_SIZE) {
+ const batchIds = ids.slice(offset, offset + BATCH_SIZE)
+ const { data } = await query.graph({ entity: options.entity, fields: options.fields, filters: { id: { $in: batchIds }, ...(options.entity === 'product_category' ? { is_active: true } : {}) } })
+ const localizedRecords = await applyLocalizedTranslations(query, asRecords(data), options.locale)
+ const documents = localizedRecords.map(options.transform)
+
+ for (const document of documents) {
+ const id = getId(document)
+
+ if (id) {
+ currentIds.add(id)
+ }
+ }
+
+ await client.addDocuments(options.index, documents)
+ }
+
+ return currentIds
+}
+
+const resolvePayloadService = (container: MedusaContainer): PayloadModuleService | null => {
+ try {
+ return container.resolve(PAYLOAD_MODULE)
+ } catch {
+ return null
+ }
+}
+
+const indexContentDocuments = async (
+ payload: PayloadModuleService | null,
+ client: MeilisearchAdminClient,
+ profile: SearchProfile,
+ index: string
+): Promise> => {
+ const currentIds = new Set()
+
+ if (!payload) {
+ return currentIds
+ }
+
+ const indexCollection = async (type: 'article' | 'page') => {
+ let page = 1
+
+ while (true) {
+ const result = type === 'article' ? await payload.listPublishedArticles({ limit: BATCH_SIZE, locale: profile.locale, page: page }) : await payload.listPublishedPages({ limit: BATCH_SIZE, locale: profile.locale, page: page })
+ const documents = result.docs.map((document) => buildContentSearchDocument(document as Record, type, profile.locale))
+
+ for (const document of documents) {
+ const id = getId(document)
+
+ if (id) {
+ currentIds.add(id)
+ }
+ }
+
+ await client.addDocuments(index, documents)
+
+ if (!result.hasNextPage) {
+ break
+ }
+
+ page += 1
+ }
+ }
+
+ await indexCollection('page')
+ await indexCollection('article')
+
+ return currentIds
+}
+
+const createTargets = (profile: SearchProfile, mode: SearchProfileSyncMode): SearchSyncTargets => {
+ if (mode === 'normal') {
+ return profile.indexes
+ }
+
+ const suffix = 'build_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
+
+ return Object.fromEntries(Object.entries(profile.indexes).map(([type, index]) => [type, index + '__' + suffix])) as SearchSyncTargets
+}
+
+const prepareTargets = async (client: MeilisearchAdminClient, targets: SearchSyncTargets) => {
+ for (const type of Object.keys(targets) as SearchIndexType[]) {
+ const index = targets[type]
+
+ await client.ensureIndex(index)
+ await client.updateSettings(index, SEARCH_INDEX_SETTINGS[type] as Record)
+ }
+}
+
+const finalizeFullSync = async (client: MeilisearchAdminClient, profile: SearchProfile, targets: SearchSyncTargets) => {
+ const completionMarkerId = 'search_build_marker_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
+
+ for (const type of SEARCH_INDEX_TYPES) {
+ await client.ensureIndex(profile.indexes[type])
+ }
+
+ await client.addDocuments(targets.content, [{ id: completionMarkerId }])
+ await client.swapIndexPairs(SEARCH_INDEX_TYPES.map((type) => ({ first: profile.indexes[type], second: targets[type] })), { index: profile.indexes.content, documentId: completionMarkerId })
+ await client.deleteDocuments(profile.indexes.content, [completionMarkerId])
+
+ for (const type of SEARCH_INDEX_TYPES) {
+ await client.deleteIndex(targets[type])
+ }
+}
+
+const cleanupBuildTargets = async (client: MeilisearchAdminClient, targets: SearchSyncTargets, logger: Logger) => {
+ for (const index of Object.values(targets)) {
+ try {
+ await client.deleteIndex(index)
+ } catch (error) {
+ logger.warn('Unable to clean temporary Meilisearch index ' + index + ': ' + (error instanceof Error ? error.message : String(error)))
+ }
+ }
+}
+
+const syncProfile = async (options: {
+ client: MeilisearchAdminClient
+ container: MedusaContainer
+ logger: Logger
+ mode: SearchProfileSyncMode
+ profile: SearchProfile
+}): Promise<{ deleted: number; indexed: number }> => {
+ const { client, container, logger, mode, profile } = options
+ const query = container.resolve(ContainerRegistrationKeys.QUERY)
+ const database = container.resolve(ContainerRegistrationKeys.PG_CONNECTION)
+ const payload = resolvePayloadService(container)
+ const targets = createTargets(profile, mode)
+
+ let finalized = false
+
+ try {
+ await prepareTargets(client, targets)
+
+ const popularityByProductId = await readProductPopularity(database, profile)
+
+ const products = await indexProductDocuments({
+ query: query,
+ client: client,
+ profile: profile,
+ index: targets.product,
+ popularityByProductId: popularityByProductId
+ })
+
+ const categoryIds = await indexReferencedEntities(query, client, {
+ entity: 'product_category',
+ fields: CATEGORY_FIELDS,
+ ids: products.references.categoryIds,
+ index: targets.category,
+ locale: profile.locale,
+
+ transform: (document) => {
+ const category = buildCategorySearchDocument(document)
+ const id = getId(category)
+
+ if (!id || profile.strict) {
+ return category
+ }
+
+ const productTitles = products.references.categoryProductTitles.get(id) ?? []
+
+ return { ...category, ...(productTitles.length > 0 ? { product_titles: productTitles.join(' ').slice(0, 10000) } : {}) }
+ }
+ })
+
+ const brandIds = await indexReferencedEntities(query, client, {
+ entity: 'brand',
+ fields: BRAND_FIELDS,
+ ids: products.references.brandIds,
+ index: targets.brand,
+ locale: profile.locale,
+ transform: buildBrandSearchDocument
+ })
+
+ const contentIds = await indexContentDocuments(payload, client, profile, targets.content)
+
+ let deleted = 0
+
+ if (mode === 'normal') {
+ deleted += await deleteStaleDocuments(client, targets.product, products.ids)
+ deleted += await deleteStaleDocuments(client, targets.category, categoryIds)
+ deleted += await deleteStaleDocuments(client, targets.brand, brandIds)
+ deleted += await deleteStaleDocuments(client, targets.content, contentIds)
+ } else {
+ await finalizeFullSync(client, profile, targets)
+
+ finalized = true
+ }
+
+ const indexed = products.indexed + categoryIds.size + brandIds.size + contentIds.size
+
+ logger.info('Meilisearch profile ' + profile.key + ' synchronized: mode=' + mode + ', indexed=' + indexed + ', deleted=' + deleted)
+
+ return { deleted, indexed }
+ } finally {
+ if (mode === 'full' && !finalized) {
+ await cleanupBuildTargets(client, targets, logger)
+ }
+ }
+}
+
+const updateProfileSyncState = async (options: { container: MedusaContainer; logger: Logger; profile: SearchProfile; state: Record }) => {
+ if (!options.profile.id) {
+ return
+ }
+
+ try {
+ const service = options.container.resolve(SEARCH_PROFILE_MODULE)
+ const update = { id: options.profile.id, ...options.state }
+
+ await service.updateSearchProfiles(update)
+ } catch (error) {
+ options.logger.warn('Unable to record Meilisearch sync state for ' + options.profile.key + ': ' + (error instanceof Error ? error.message : String(error)))
+ }
+}
+
+const syncProfileWithStatus = async (options: {
+ client: MeilisearchAdminClient
+ container: MedusaContainer
+ logger: Logger
+ mode: SearchProfileSyncMode
+ profile: SearchProfile
+}): Promise<{ deleted: number; indexed: number }> => {
+ const runningState = { ...options, state: { last_sync_status: 'running', last_sync_mode: options.mode, last_sync_started_at: new Date(), last_sync_error: null } }
+
+ await updateProfileSyncState(runningState)
+
+ try {
+ const result = await syncProfile(options)
+
+ const succeededState = {
+ ...options,
+
+ state: {
+ last_sync_status: 'succeeded',
+ last_sync_mode: options.mode,
+ last_synced_at: new Date(),
+ last_sync_error: null,
+ last_indexed_count: result.indexed,
+ last_deleted_count: result.deleted
+ }
+ }
+
+ await updateProfileSyncState(succeededState)
+
+ return result
+ } catch (error) {
+ const failedState = { ...options, state: { last_sync_status: 'failed', last_sync_mode: options.mode, last_sync_error: error instanceof Error ? error.message : String(error) } }
+
+ await updateProfileSyncState(failedState)
+
+ throw error
+ }
+}
+
+const synchronizeUnlocked = async (
+ container: MedusaContainer,
+ mode: SearchProfileSyncMode,
+ options?: SearchProfileSyncOptions
+): Promise => {
+ const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
+
+ if (!isMeilisearchEnabled()) {
+ logger.info('Skipping search profile sync because Meilisearch is disabled')
+
+ return { deleted: 0, indexed: 0, mode: mode, profiles: 0 }
+ }
+
+ const configuredProfiles = await loadSearchProfiles(container)
+ const requestedKeys = options?.profileKeys?.length ? new Set(options.profileKeys) : undefined
+ const profiles = requestedKeys ? configuredProfiles.filter((profile) => requestedKeys.has(profile.key)) : configuredProfiles
+ const client = new MeilisearchAdminClient()
+
+ let deleted = 0
+ let indexed = 0
+
+ for (const profile of profiles) {
+ const result = await syncProfileWithStatus({
+ client: client,
+ container: container,
+ logger: logger,
+ mode: mode,
+ profile: profile
+ })
+
+ deleted += result.deleted
+ indexed += result.indexed
+ }
+
+ return { deleted: deleted, indexed: indexed, mode: mode, profiles: profiles.length }
+}
+
+export const synchronizeSearchProfiles = async (
+ container: MedusaContainer,
+ mode: SearchProfileSyncMode,
+ options?: SearchProfileSyncOptions
+): Promise => {
+ const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
+ const locking = container.resolve(Modules.LOCKING)
+
+ try {
+ let result: SearchProfileSyncResult = {
+ deleted: 0,
+ indexed: 0,
+ mode,
+ profiles: 0
+ }
+
+ await locking.execute(SEARCH_SYNC_LOCK_KEY, async () => {
+ result = await synchronizeUnlocked(container, mode, options)
+ }, { timeout: 1 })
+
+ return result
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('Timed-out')) {
+ logger.info('Skipping ' + mode + ' Meilisearch sync because another instance holds the lock')
+
+ return { deleted: 0, indexed: 0, mode: mode, profiles: 0 }
+ }
+
+ throw error
+ }
+}
diff --git a/apps/medusa-be/src/modules/payload/service.ts b/apps/medusa-be/src/modules/payload/service.ts
index 05029163d4..75df083d2e 100644
--- a/apps/medusa-be/src/modules/payload/service.ts
+++ b/apps/medusa-be/src/modules/payload/service.ts
@@ -385,6 +385,37 @@ export default class PayloadModuleService extends MedusaService({}) {
)
}
+ /**
+ * List published public pages for search indexing.
+ */
+ async listPublishedPages(options?: {
+ limit?: number
+ locale?: string
+ page?: number
+ }): Promise> {
+ const queryString = this.buildQuery({
+ where: {
+ status: { equals: STATUS_PUBLISHED },
+ visibility: { equals: "public" },
+ },
+ limit: options?.limit ?? 100,
+ page: options?.page ?? 1,
+ locale: options?.locale,
+ })
+
+ return this.makeRequest>(
+ "GET",
+ `/${PAGES}${queryString}`,
+ undefined,
+ {
+ schema: CmsPagesBulkResultSchema,
+ headers: {
+ [RETURN_HTML_HEADER]: "true",
+ },
+ }
+ )
+ }
+
/**
* List page categories and their pages, optionally filtered by locale/slug.
*/
@@ -460,6 +491,36 @@ export default class PayloadModuleService extends MedusaService({}) {
)
}
+ /**
+ * List published articles for search indexing.
+ */
+ async listPublishedArticles(options?: {
+ limit?: number
+ locale?: string
+ page?: number
+ }): Promise> {
+ const queryString = this.buildQuery({
+ where: {
+ status: { equals: STATUS_PUBLISHED },
+ },
+ limit: options?.limit ?? 100,
+ page: options?.page ?? 1,
+ locale: options?.locale,
+ })
+
+ return this.makeRequest>(
+ "GET",
+ `/${ARTICLES}${queryString}`,
+ undefined,
+ {
+ schema: CmsArticlesBulkResultSchema,
+ headers: {
+ [RETURN_HTML_HEADER]: "true",
+ },
+ }
+ )
+ }
+
/**
* List article categories and their articles, optionally filtered by locale/slug.
*/
diff --git a/apps/medusa-be/src/modules/search-profile/index.ts b/apps/medusa-be/src/modules/search-profile/index.ts
new file mode 100644
index 0000000000..c213b15061
--- /dev/null
+++ b/apps/medusa-be/src/modules/search-profile/index.ts
@@ -0,0 +1,12 @@
+import { Module } from '@medusajs/framework/utils'
+import bootstrapSearchProfiles from './loaders/bootstrap-search-profiles'
+import SearchProfileModuleService from './service'
+
+export const SEARCH_PROFILE_MODULE = 'search_profile'
+export default Module(SEARCH_PROFILE_MODULE, { service: SearchProfileModuleService, loaders: [bootstrapSearchProfiles] })
+export type { default as SearchProfileModuleService } from './service'
+
+export type {
+ SearchProfileDTO,
+ SearchProfileWriteInput
+} from './types'
diff --git a/apps/medusa-be/src/modules/search-profile/loaders/bootstrap-search-profiles.ts b/apps/medusa-be/src/modules/search-profile/loaders/bootstrap-search-profiles.ts
new file mode 100644
index 0000000000..7bb679a6c6
--- /dev/null
+++ b/apps/medusa-be/src/modules/search-profile/loaders/bootstrap-search-profiles.ts
@@ -0,0 +1,65 @@
+import type { LoaderOptions } from '@medusajs/framework/types'
+import { ContainerRegistrationKeys } from '@medusajs/framework/utils'
+import { readSearchProfiles, type SearchProfile } from '../../meilisearch/profiles'
+
+type InternalSearchProfileService = {
+ create: (data: Record) => Promise
+ listAndCount: (filter: Record) => Promise<[unknown[], number]>
+}
+
+const toBootstrapRecord = (profile: SearchProfile) => {
+ const automaticRankingScore = profile.strict ? 0.98 : 0.55
+
+ return {
+ key: profile.key,
+ shop: profile.shop,
+ domain: profile.domain,
+ locale: profile.locale,
+ sales_channel_ids: profile.salesChannelIds,
+ strict: profile.strict,
+ separate_variant_results: profile.separateVariantResults,
+ minimum_ranking_score: profile.minimumRankingScore === automaticRankingScore ? null : profile.minimumRankingScore,
+ availability: profile.availability,
+ autocomplete_product_limit: profile.limits.autocomplete.product,
+ autocomplete_category_limit: profile.limits.autocomplete.category,
+ autocomplete_brand_limit: profile.limits.autocomplete.brand,
+ autocomplete_content_limit: profile.limits.autocomplete.content,
+ full_search_limit: profile.limits.fullSearch,
+ max_results_per_page: profile.limits.page,
+ popular_limit: profile.limits.popular
+ }
+}
+
+export default async function bootstrapSearchProfiles({ container }: LoaderOptions) {
+ const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
+ const service = container.resolve('searchProfileService')
+ const [, count] = await service.listAndCount({})
+
+ if (count > 0) {
+ return
+ }
+
+ if (!process.env.MEILISEARCH_SEARCH_PROFILES?.trim()) {
+ logger.info('Meilisearch: no search profiles configured; create one in Medusa Admin')
+
+ return
+ }
+
+ const profiles = readSearchProfiles()
+
+ for (const profile of profiles) {
+ try {
+ await service.create(toBootstrapRecord(profile))
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+
+ if (message.includes('unique constraint') || message.includes('duplicate key')) {
+ continue
+ }
+
+ throw error
+ }
+ }
+
+ logger.info('Meilisearch: bootstrapped ' + profiles.length + ' search profile(s) into Medusa')
+}
diff --git a/apps/medusa-be/src/modules/search-profile/migrations/.snapshot-search-profile.json b/apps/medusa-be/src/modules/search-profile/migrations/.snapshot-search-profile.json
new file mode 100644
index 0000000000..2e52bb6c2d
--- /dev/null
+++ b/apps/medusa-be/src/modules/search-profile/migrations/.snapshot-search-profile.json
@@ -0,0 +1,337 @@
+{
+ "namespaces": ["public"],
+ "name": "public",
+ "tables": [
+ {
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": true,
+ "nullable": false,
+ "mappedType": "text"
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "mappedType": "text"
+ },
+ "shop": {
+ "name": "shop",
+ "type": "text",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "mappedType": "text"
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "mappedType": "text"
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "mappedType": "text"
+ },
+ "sales_channel_ids": {
+ "name": "sales_channel_ids",
+ "type": "jsonb",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "mappedType": "json"
+ },
+ "strict": {
+ "name": "strict",
+ "type": "boolean",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "false",
+ "mappedType": "boolean"
+ },
+ "separate_variant_results": {
+ "name": "separate_variant_results",
+ "type": "boolean",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "false",
+ "mappedType": "boolean"
+ },
+ "minimum_ranking_score": {
+ "name": "minimum_ranking_score",
+ "type": "float4",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": true,
+ "precision": 24,
+ "mappedType": "float"
+ },
+ "availability": {
+ "name": "availability",
+ "type": "text",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "'all'",
+ "mappedType": "text"
+ },
+ "autocomplete_product_limit": {
+ "name": "autocomplete_product_limit",
+ "type": "integer",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "6",
+ "mappedType": "integer"
+ },
+ "autocomplete_category_limit": {
+ "name": "autocomplete_category_limit",
+ "type": "integer",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "3",
+ "mappedType": "integer"
+ },
+ "autocomplete_brand_limit": {
+ "name": "autocomplete_brand_limit",
+ "type": "integer",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "3",
+ "mappedType": "integer"
+ },
+ "autocomplete_content_limit": {
+ "name": "autocomplete_content_limit",
+ "type": "integer",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "3",
+ "mappedType": "integer"
+ },
+ "full_search_limit": {
+ "name": "full_search_limit",
+ "type": "integer",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "500",
+ "mappedType": "integer"
+ },
+ "max_results_per_page": {
+ "name": "max_results_per_page",
+ "type": "integer",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "100",
+ "mappedType": "integer"
+ },
+ "popular_limit": {
+ "name": "popular_limit",
+ "type": "integer",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "12",
+ "mappedType": "integer"
+ },
+ "last_sync_status": {
+ "name": "last_sync_status",
+ "type": "text",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "'never'",
+ "mappedType": "text"
+ },
+ "last_sync_mode": {
+ "name": "last_sync_mode",
+ "type": "text",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": true,
+ "mappedType": "text"
+ },
+ "last_sync_started_at": {
+ "name": "last_sync_started_at",
+ "type": "timestamptz",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": true,
+ "length": 6,
+ "mappedType": "datetime"
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "timestamptz",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": true,
+ "length": 6,
+ "mappedType": "datetime"
+ },
+ "last_sync_error": {
+ "name": "last_sync_error",
+ "type": "text",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": true,
+ "mappedType": "text"
+ },
+ "last_indexed_count": {
+ "name": "last_indexed_count",
+ "type": "integer",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "0",
+ "mappedType": "integer"
+ },
+ "last_deleted_count": {
+ "name": "last_deleted_count",
+ "type": "integer",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "default": "0",
+ "mappedType": "integer"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamptz",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "length": 6,
+ "default": "now()",
+ "mappedType": "datetime"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamptz",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": false,
+ "length": 6,
+ "default": "now()",
+ "mappedType": "datetime"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamptz",
+ "unsigned": false,
+ "autoincrement": false,
+ "primary": false,
+ "nullable": true,
+ "length": 6,
+ "mappedType": "datetime"
+ }
+ },
+ "name": "search_profile",
+ "schema": "public",
+ "indexes": [
+ {
+ "keyName": "IDX_search_profile_deleted_at",
+ "columnNames": [],
+ "composite": false,
+ "constraint": false,
+ "primary": false,
+ "unique": false,
+ "expression": "CREATE INDEX IF NOT EXISTS \"IDX_search_profile_deleted_at\" ON \"search_profile\" (\"deleted_at\") WHERE deleted_at IS NULL"
+ },
+ {
+ "keyName": "IDX_search_profile_key_unique",
+ "columnNames": [],
+ "composite": false,
+ "constraint": false,
+ "primary": false,
+ "unique": false,
+ "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_search_profile_key_unique\" ON \"search_profile\" (\"key\") WHERE deleted_at IS NULL"
+ },
+ {
+ "keyName": "IDX_search_profile_scope_unique",
+ "columnNames": [],
+ "composite": false,
+ "constraint": false,
+ "primary": false,
+ "unique": false,
+ "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_search_profile_scope_unique\" ON \"search_profile\" (\"shop\", \"domain\", \"locale\") WHERE deleted_at IS NULL"
+ },
+ {
+ "keyName": "search_profile_pkey",
+ "columnNames": ["id"],
+ "composite": false,
+ "constraint": true,
+ "primary": true,
+ "unique": true
+ }
+ ],
+ "checks": [
+ {
+ "name": "search_profile_availability_check",
+ "expression": "availability in ('all', 'in-stock')",
+ "definition": "check ((availability = any (array['all'::text, 'in-stock'::text])))"
+ },
+ {
+ "name": "search_profile_minimum_ranking_score_check",
+ "expression": "minimum_ranking_score is null or (minimum_ranking_score >= 0 and minimum_ranking_score <= 1)",
+ "definition": "check (((minimum_ranking_score is null) or ((minimum_ranking_score >= (0)::double precision) and (minimum_ranking_score <= (1)::double precision))))"
+ },
+ {
+ "name": "search_profile_sync_status_check",
+ "expression": "last_sync_status in ('never', 'running', 'succeeded', 'failed')",
+ "definition": "check ((last_sync_status = any (array['never'::text, 'running'::text, 'succeeded'::text, 'failed'::text])))"
+ },
+ {
+ "name": "search_profile_sync_mode_check",
+ "expression": "last_sync_mode is null or last_sync_mode in ('normal', 'full')",
+ "definition": "check (((last_sync_mode is null) or (last_sync_mode = any (array['normal'::text, 'full'::text]))))"
+ }
+ ],
+ "foreignKeys": {},
+ "nativeEnums": {}
+ }
+ ]
+}
diff --git a/apps/medusa-be/src/modules/search-profile/migrations/Migration20260730173000.ts b/apps/medusa-be/src/modules/search-profile/migrations/Migration20260730173000.ts
new file mode 100644
index 0000000000..01412e93c0
--- /dev/null
+++ b/apps/medusa-be/src/modules/search-profile/migrations/Migration20260730173000.ts
@@ -0,0 +1,18 @@
+import { Migration } from '@medusajs/framework/mikro-orm/migrations'
+
+export class Migration20260730173000 extends Migration {
+ override async up(): Promise {
+ this.addSql('create table if not exists "search_profile" ("id" text not null, "key" text not null, "shop" text not null, "domain" text not null, "locale" text not null, "sales_channel_ids" jsonb not null, "strict" boolean not null default false, "separate_variant_results" boolean not null default false, "minimum_ranking_score" real null, "availability" text not null default \'all\', "autocomplete_product_limit" integer not null default 6, "autocomplete_category_limit" integer not null default 3, "autocomplete_brand_limit" integer not null default 3, "autocomplete_content_limit" integer not null default 3, "full_search_limit" integer not null default 500, "max_results_per_page" integer not null default 100, "popular_limit" integer not null default 12, "last_sync_status" text not null default \'never\', "last_sync_mode" text null, "last_sync_started_at" timestamptz null, "last_synced_at" timestamptz null, "last_sync_error" text null, "last_indexed_count" integer not null default 0, "last_deleted_count" integer not null default 0, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "search_profile_pkey" primary key ("id"));')
+ this.addSql('create index if not exists "IDX_search_profile_deleted_at" on "search_profile" ("deleted_at") where deleted_at is null;')
+ this.addSql('create unique index if not exists "IDX_search_profile_key_unique" on "search_profile" ("key") where deleted_at is null;')
+ this.addSql('create unique index if not exists "IDX_search_profile_scope_unique" on "search_profile" ("shop", "domain", "locale") where deleted_at is null;')
+ this.addSql('alter table if exists "search_profile" add constraint "search_profile_availability_check" check ("availability" in (\'all\', \'in-stock\'));')
+ this.addSql('alter table if exists "search_profile" add constraint "search_profile_minimum_ranking_score_check" check ("minimum_ranking_score" is null or ("minimum_ranking_score" >= 0 and "minimum_ranking_score" <= 1));')
+ this.addSql('alter table if exists "search_profile" add constraint "search_profile_sync_status_check" check ("last_sync_status" in (\'never\', \'running\', \'succeeded\', \'failed\'));')
+ this.addSql('alter table if exists "search_profile" add constraint "search_profile_sync_mode_check" check ("last_sync_mode" is null or "last_sync_mode" in (\'normal\', \'full\'));')
+ }
+
+ override async down(): Promise {
+ this.addSql('drop table if exists "search_profile" cascade;')
+ }
+}
diff --git a/apps/medusa-be/src/modules/search-profile/models/search-profile.ts b/apps/medusa-be/src/modules/search-profile/models/search-profile.ts
new file mode 100644
index 0000000000..8064f48dc6
--- /dev/null
+++ b/apps/medusa-be/src/modules/search-profile/models/search-profile.ts
@@ -0,0 +1,63 @@
+import { model } from '@medusajs/framework/utils'
+
+const SearchProfile = model
+ .define('search_profile', {
+ id: model.id().primaryKey(),
+ key: model.text().searchable(),
+ shop: model.text().searchable(),
+ domain: model.text().searchable(),
+ locale: model.text().searchable(),
+ sales_channel_ids: model.json(),
+ strict: model.boolean().default(false),
+ separate_variant_results: model.boolean().default(false),
+ minimum_ranking_score: model.float().nullable(),
+ availability: model.text().default('all'),
+ autocomplete_product_limit: model.number().default(6),
+ autocomplete_category_limit: model.number().default(3),
+ autocomplete_brand_limit: model.number().default(3),
+ autocomplete_content_limit: model.number().default(3),
+ full_search_limit: model.number().default(500),
+ max_results_per_page: model.number().default(100),
+ popular_limit: model.number().default(12),
+ last_sync_status: model.text().default('never'),
+ last_sync_mode: model.text().nullable(),
+ last_sync_started_at: model.dateTime().nullable(),
+ last_synced_at: model.dateTime().nullable(),
+ last_sync_error: model.text().nullable(),
+ last_indexed_count: model.number().default(0),
+ last_deleted_count: model.number().default(0)
+ })
+ .indexes([
+ {
+ name: 'IDX_search_profile_key_unique',
+ on: ['key'],
+ unique: true,
+ where: 'deleted_at IS NULL'
+ },
+ {
+ name: 'IDX_search_profile_scope_unique',
+ on: ['shop', 'domain', 'locale'],
+ unique: true,
+ where: 'deleted_at IS NULL'
+ }
+ ])
+ .checks([
+ {
+ name: 'search_profile_availability_check',
+ expression: (columns) => columns.availability + ' in (\'all\', \'in-stock\')'
+ },
+ {
+ name: 'search_profile_minimum_ranking_score_check',
+ expression: (columns) => columns.minimum_ranking_score + ' is null or (' + columns.minimum_ranking_score + ' >= 0 and ' + columns.minimum_ranking_score + ' <= 1)'
+ },
+ {
+ name: 'search_profile_sync_status_check',
+ expression: (columns) => columns.last_sync_status + ' in (\'never\', \'running\', \'succeeded\', \'failed\')'
+ },
+ {
+ name: 'search_profile_sync_mode_check',
+ expression: (columns) => columns.last_sync_mode + ' is null or ' + columns.last_sync_mode + ' in (\'normal\', \'full\')'
+ }
+ ])
+
+export default SearchProfile
diff --git a/apps/medusa-be/src/modules/search-profile/service.ts b/apps/medusa-be/src/modules/search-profile/service.ts
new file mode 100644
index 0000000000..7f08d09f0c
--- /dev/null
+++ b/apps/medusa-be/src/modules/search-profile/service.ts
@@ -0,0 +1,59 @@
+import type { ICachingModuleService } from '@medusajs/framework/types'
+import { MedusaService, Modules } from '@medusajs/framework/utils'
+import { safeResolve } from '../../utils/safe-resolve'
+import SearchProfile from './models/search-profile'
+import type { SearchProfileDTO } from './types'
+
+const RUNTIME_PROFILES_CACHE_KEY = 'search-profile:runtime:enabled'
+const RUNTIME_PROFILES_CACHE_TTL_SECONDS = 30
+
+type InjectedDependencies = {
+ [Modules.CACHING]?: ICachingModuleService
+}
+
+class SearchProfileModuleService extends MedusaService({ SearchProfile }) {
+ private readonly cacheService_: ICachingModuleService | null
+
+ constructor(container: InjectedDependencies) {
+ super(container)
+
+ this.cacheService_ = safeResolve(container, Modules.CACHING)
+ }
+
+ async listConfiguredProfiles(options?: { enabledOnly?: boolean }): Promise {
+ const profiles = await this.listSearchProfiles({}, { order: { shop: 'ASC', domain: 'ASC', locale: 'ASC', key: 'ASC' } })
+ const configuredProfiles = profiles as unknown as SearchProfileDTO[]
+
+ return options?.enabledOnly ? configuredProfiles.filter((profile) => Array.isArray(profile.sales_channel_ids) && profile.sales_channel_ids.length > 0) : configuredProfiles
+ }
+
+ async listRuntimeProfiles(): Promise {
+ if (this.cacheService_) {
+ const cached = await this.cacheService_.get({ key: RUNTIME_PROFILES_CACHE_KEY })
+
+ if (Array.isArray(cached)) {
+ return cached as SearchProfileDTO[]
+ }
+ }
+
+ const profiles = await this.listConfiguredProfiles({ enabledOnly: true })
+
+ if (this.cacheService_) {
+ await this.cacheService_.set({
+ key: RUNTIME_PROFILES_CACHE_KEY,
+ data: profiles,
+ tags: [],
+ ttl: RUNTIME_PROFILES_CACHE_TTL_SECONDS,
+ options: { autoInvalidate: false }
+ })
+ }
+
+ return profiles
+ }
+
+ async invalidateRuntimeProfileCache(): Promise {
+ await this.cacheService_?.clear({ key: RUNTIME_PROFILES_CACHE_KEY })
+ }
+}
+
+export default SearchProfileModuleService
diff --git a/apps/medusa-be/src/modules/search-profile/types.ts b/apps/medusa-be/src/modules/search-profile/types.ts
new file mode 100644
index 0000000000..a1c4b90c4d
--- /dev/null
+++ b/apps/medusa-be/src/modules/search-profile/types.ts
@@ -0,0 +1,44 @@
+export type SearchProfileDTO = {
+ id: string
+ key: string
+ shop: string
+ domain: string
+ locale: string
+ sales_channel_ids: string[]
+ strict: boolean
+ separate_variant_results: boolean
+ minimum_ranking_score: number | null
+ availability: 'all' | 'in-stock'
+ autocomplete_product_limit: number
+ autocomplete_category_limit: number
+ autocomplete_brand_limit: number
+ autocomplete_content_limit: number
+ full_search_limit: number
+ max_results_per_page: number
+ popular_limit: number
+ last_sync_status: 'never' | 'running' | 'succeeded' | 'failed'
+ last_sync_mode: 'normal' | 'full' | null
+ last_sync_started_at: Date | string | null
+ last_synced_at: Date | string | null
+ last_sync_error: string | null
+ last_indexed_count: number
+ last_deleted_count: number
+ created_at: Date | string
+ updated_at: Date | string
+ deleted_at?: Date | string | null
+}
+
+export type SearchProfileWriteInput = Omit<
+ SearchProfileDTO,
+ | 'id'
+ | 'created_at'
+ | 'updated_at'
+ | 'deleted_at'
+ | 'last_sync_status'
+ | 'last_sync_mode'
+ | 'last_sync_started_at'
+ | 'last_synced_at'
+ | 'last_sync_error'
+ | 'last_indexed_count'
+ | 'last_deleted_count'
+>
diff --git a/apps/medusa-be/src/modules/storefront-text/definitions/search.ts b/apps/medusa-be/src/modules/storefront-text/definitions/search.ts
index b981815434..f5b061572f 100644
--- a/apps/medusa-be/src/modules/storefront-text/definitions/search.ts
+++ b/apps/medusa-be/src/modules/storefront-text/definitions/search.ts
@@ -51,6 +51,11 @@ export const STOREFRONT_SEARCH_TEXT_DEFINITIONS = [
key: "search.autocomplete.sections.brands",
namespace: "search",
},
+ {
+ description: "Nadpis sekce obsahu v návrzích vyhledávání.",
+ key: "search.autocomplete.sections.content",
+ namespace: "search",
+ },
{
description: "Typ návrhu pro kategorii.",
key: "search.autocomplete.types.category",
diff --git a/apps/medusa-be/src/modules/storefront-text/messages/cs-CZ.json b/apps/medusa-be/src/modules/storefront-text/messages/cs-CZ.json
index 1e6f856f82..351b4690af 100644
--- a/apps/medusa-be/src/modules/storefront-text/messages/cs-CZ.json
+++ b/apps/medusa-be/src/modules/storefront-text/messages/cs-CZ.json
@@ -725,7 +725,8 @@
"sections": {
"products": "Produkty",
"categories": "Kategorie",
- "brands": "Značky"
+ "brands": "Značky",
+ "content": "Obsah"
},
"types": {
"category": "Kategorie",
diff --git a/apps/medusa-be/src/modules/storefront-text/messages/hu-HU.json b/apps/medusa-be/src/modules/storefront-text/messages/hu-HU.json
index 7af32c09ab..c2aa51bbc1 100644
--- a/apps/medusa-be/src/modules/storefront-text/messages/hu-HU.json
+++ b/apps/medusa-be/src/modules/storefront-text/messages/hu-HU.json
@@ -725,7 +725,8 @@
"sections": {
"products": "Termékek",
"categories": "Kategóriák",
- "brands": "Márkák"
+ "brands": "Márkák",
+ "content": "Tartalom"
},
"types": {
"category": "Kategória",
diff --git a/apps/medusa-be/src/modules/storefront-text/messages/ro-RO.json b/apps/medusa-be/src/modules/storefront-text/messages/ro-RO.json
index e04794c9a7..2011f0b86e 100644
--- a/apps/medusa-be/src/modules/storefront-text/messages/ro-RO.json
+++ b/apps/medusa-be/src/modules/storefront-text/messages/ro-RO.json
@@ -725,7 +725,8 @@
"sections": {
"products": "Produse",
"categories": "Categorii",
- "brands": "Mărci"
+ "brands": "Mărci",
+ "content": "Conținut"
},
"types": {
"category": "Categorie",
diff --git a/apps/medusa-be/src/modules/storefront-text/messages/sk-SK.json b/apps/medusa-be/src/modules/storefront-text/messages/sk-SK.json
index a540b05897..89b53a30c4 100644
--- a/apps/medusa-be/src/modules/storefront-text/messages/sk-SK.json
+++ b/apps/medusa-be/src/modules/storefront-text/messages/sk-SK.json
@@ -725,7 +725,8 @@
"sections": {
"products": "Produkty",
"categories": "Kategórie",
- "brands": "Značky"
+ "brands": "Značky",
+ "content": "Obsah"
},
"types": {
"category": "Kategória",
diff --git a/apps/medusa-be/src/scripts/search-index.ts b/apps/medusa-be/src/scripts/search-index.ts
index d62ed56588..aa8251a4ee 100644
--- a/apps/medusa-be/src/scripts/search-index.ts
+++ b/apps/medusa-be/src/scripts/search-index.ts
@@ -1,295 +1,20 @@
-import type { ExecArgs, Logger, Query } from "@medusajs/framework/types"
+import type { ExecArgs, Logger } from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
-import type { MeiliSearchService } from "@rokmohar/medusa-plugin-meilisearch"
-import { isMeilisearchEnabled } from "../modules/meilisearch/env"
-
-const BATCH_SIZE = 1000
-
-type SyncEntityConfig = {
- entity: "product" | "product_category" | "brand"
- entityType: "products" | "categories" | "brands"
- filters?: Record
-}
-
-type SyncEntityServices = {
- container: ExecArgs["container"]
- logger: Logger
- meilisearchIndexService: MeiliSearchService
- queryService: Query
-}
-
-type SyncEntityContext = SyncEntityServices & {
- config: SyncEntityConfig
- fields: string[]
- indexes: string[]
-}
-
-type SyncEntityResult = {
- indexed: number
- deleted: number
-}
-
-const SYNC_ENTITIES: SyncEntityConfig[] = [
- {
- entity: "product",
- entityType: "products",
- filters: {
- status: "published",
- },
- },
- {
- entity: "product_category",
- entityType: "categories",
- filters: {
- is_active: true,
- },
- },
- {
- entity: "brand",
- entityType: "brands",
- },
-]
-
-const resolveRecordId = (record: unknown): string | undefined => {
- if (!record || typeof record !== "object" || Array.isArray(record)) {
- return
- }
-
- const id = (record as { id?: unknown }).id
- if (typeof id === "string" && id.trim()) {
- return id
- }
- if (typeof id === "number" && Number.isFinite(id)) {
- return String(id)
- }
-
- return
-}
-
-const EMPTY_SYNC_ENTITY_RESULT: SyncEntityResult = {
- indexed: 0,
- deleted: 0,
-}
-
-async function fetchEntityBatch(
- { config, fields, queryService }: SyncEntityContext,
- offset: number
-): Promise {
- const { data } = await queryService.graph({
- entity: config.entity,
- fields,
- pagination: {
- take: BATCH_SIZE,
- skip: offset,
- },
- filters: config.filters,
- })
-
- return Array.isArray(data) ? data : []
-}
-
-async function indexEntityBatch(
- { config, container, indexes, meilisearchIndexService }: SyncEntityContext,
- records: unknown[]
-) {
- await Promise.all(
- indexes.map((index) =>
- meilisearchIndexService.addDocuments(index, records, config.entityType, {
- container,
- })
- )
- )
-}
-
-function addIndexedRecordIds(indexedIds: Set, records: unknown[]) {
- for (const record of records) {
- const id = resolveRecordId(record)
- if (id) {
- indexedIds.add(id)
- }
- }
-}
-
-async function indexEntityRecords(
- context: SyncEntityContext
-): Promise> {
- const indexedIds = new Set()
- let offset = 0
-
- while (true) {
- const records = await fetchEntityBatch(context, offset)
- if (records.length === 0) {
- break
- }
-
- await indexEntityBatch(context, records)
- addIndexedRecordIds(indexedIds, records)
-
- offset += records.length
- if (records.length < BATCH_SIZE) {
- break
- }
- }
-
- return indexedIds
-}
-
-async function collectOrphanedIdsForIndex(
- meilisearchIndexService: MeiliSearchService,
- index: string,
- indexedIds: Set
-): Promise> {
- const orphanedIds = new Set()
- let searchOffset = 0
-
- while (true) {
- const indexedResult = await meilisearchIndexService.search(index, "", {
- paginationOptions: {
- offset: searchOffset,
- limit: BATCH_SIZE,
- },
- additionalOptions: {
- attributesToRetrieve: ["id"],
- },
- })
-
- const hits = Array.isArray(indexedResult.hits) ? indexedResult.hits : []
- if (hits.length === 0) {
- break
- }
-
- for (const hit of hits) {
- const id = resolveRecordId(hit)
- if (id && !indexedIds.has(id)) {
- orphanedIds.add(id)
- }
- }
-
- searchOffset += hits.length
- if (hits.length < BATCH_SIZE) {
- break
- }
- }
-
- return orphanedIds
-}
-
-async function collectOrphanedIds(
- { indexes, meilisearchIndexService }: SyncEntityContext,
- indexedIds: Set
-): Promise> {
- const orphanedIds = new Set()
- for (const index of indexes) {
- const indexOrphanedIds = await collectOrphanedIdsForIndex(
- meilisearchIndexService,
- index,
- indexedIds
- )
- for (const id of indexOrphanedIds) {
- orphanedIds.add(id)
- }
- }
-
- return orphanedIds
-}
-
-async function deleteOrphanedIds(
- { indexes, meilisearchIndexService }: SyncEntityContext,
- orphanedIds: Set
-) {
- const idsToDelete = Array.from(orphanedIds)
- for (let cursor = 0; cursor < idsToDelete.length; cursor += BATCH_SIZE) {
- const batch = idsToDelete.slice(cursor, cursor + BATCH_SIZE)
- await Promise.all(
- indexes.map((index) =>
- meilisearchIndexService.deleteDocuments(index, batch)
- )
- )
- }
-}
-
-const syncEntityToMeilisearch = async ({
- config,
- container,
- logger,
- meilisearchIndexService,
- queryService,
-}: {
- config: SyncEntityConfig
- container: ExecArgs["container"]
- logger: Logger
- meilisearchIndexService: MeiliSearchService
- queryService: Query
-}): Promise => {
- const fields = await meilisearchIndexService.getFieldsForType(
- config.entityType
- )
- const indexes = await meilisearchIndexService.getIndexesByType(
- config.entityType
- )
-
- if (indexes.length === 0) {
- logger.info(
- `Skipping ${config.entityType} sync because no MeiliSearch indexes are configured for this type`
- )
- return EMPTY_SYNC_ENTITY_RESULT
- }
-
- const context: SyncEntityContext = {
- config,
- container,
- fields,
- indexes,
- logger,
- meilisearchIndexService,
- queryService,
- }
-
- const indexedIds = await indexEntityRecords(context)
- const orphanedIds = await collectOrphanedIds(context, indexedIds)
- if (orphanedIds.size > 0) {
- await deleteOrphanedIds(context, orphanedIds)
- }
-
- logger.info(
- `Synced ${config.entityType}: indexed=${indexedIds.size}, deleted=${orphanedIds.size}`
- )
-
- return {
- indexed: indexedIds.size,
- deleted: orphanedIds.size,
- }
-}
+import { synchronizeSearchProfiles } from "../modules/meilisearch/synchronize"
export default async function searchIndexScript({ container }: ExecArgs) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
-
- if (!isMeilisearchEnabled()) {
- logger.info("Skipping search indexing because Meilisearch is disabled")
- return
- }
-
- const queryService = container.resolve(ContainerRegistrationKeys.QUERY)
-
- const meilisearchIndexService: MeiliSearchService =
- container.resolve("meilisearch")
-
- let totalIndexed = 0
- let totalDeleted = 0
-
- for (const config of SYNC_ENTITIES) {
- const result = await syncEntityToMeilisearch({
- config,
- container,
- logger,
- meilisearchIndexService,
- queryService,
- })
- totalIndexed += result.indexed
- totalDeleted += result.deleted
- }
+ const mode = process.argv.includes("--full") ? "full" : "normal"
+ const result = await synchronizeSearchProfiles(container, mode)
logger.info(
- `MeiliSearch sync complete: indexed=${totalIndexed}, deleted=${totalDeleted}`
+ "Meilisearch search-profile sync complete: mode=" +
+ result.mode +
+ ", profiles=" +
+ result.profiles +
+ ", indexed=" +
+ result.indexed +
+ ", deleted=" +
+ result.deleted
)
}
diff --git a/apps/medusa-be/src/subscribers/meilisearch-sync-brands.ts b/apps/medusa-be/src/subscribers/meilisearch-sync-brands.ts
index 585c1077af..6d48d37654 100644
--- a/apps/medusa-be/src/subscribers/meilisearch-sync-brands.ts
+++ b/apps/medusa-be/src/subscribers/meilisearch-sync-brands.ts
@@ -1,4 +1,5 @@
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
+import { synchronizeSearchProfiles } from "../modules/meilisearch/synchronize"
import { syncMeilisearchBrandsWorkflow } from "../workflows/meilisearch/workflows/sync-brands"
export default async function meilisearchSyncBrandsHandler({
@@ -7,6 +8,7 @@ export default async function meilisearchSyncBrandsHandler({
await syncMeilisearchBrandsWorkflow(container).run({
input: {},
})
+ await synchronizeSearchProfiles(container, "normal")
}
export const config: SubscriberConfig = {
diff --git a/apps/medusa-be/src/workflows/meilisearch/steps/__tests__/brand-search-projection.unit.spec.ts b/apps/medusa-be/src/workflows/meilisearch/steps/__tests__/brand-search-projection.unit.spec.ts
deleted file mode 100644
index 72d48cb609..0000000000
--- a/apps/medusa-be/src/workflows/meilisearch/steps/__tests__/brand-search-projection.unit.spec.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import type { MedusaContainer } from "@medusajs/framework/types"
-import {
- ContainerRegistrationKeys,
- SearchUtils,
-} from "@medusajs/framework/utils"
-import { afterEach, describe, expect, it, vi } from "vitest"
-import {
- BRAND_SEARCH_PROJECTION_EVENT_OPTIONS,
- buildBrandSearchProjectionEventData,
-} from "../../events"
-import { reconcileBrandSearchProjection } from "../reconcile-brand-search-projection"
-import { resolveBrandSearchProjectionTargets } from "../resolve-brand-search-projection-targets"
-
-vi.mock("../../../../links/product-brand", () => ({
- ProductBrandLink: {
- entryPoint: "product_brand",
- },
-}))
-
-const asContainer = (resolve: (key: string) => unknown): MedusaContainer =>
- ({
- resolve: vi.fn(resolve),
- }) as unknown as MedusaContainer
-
-describe("Brand search projection", () => {
- afterEach(() => {
- vi.unstubAllEnvs()
- })
-
- it("deduplicates event targets and configures retryable delivery", () => {
- expect(
- buildBrandSearchProjectionEventData({
- brandIds: ["brand_1", "brand_1"],
- productIds: ["prod_1", "prod_1"],
- })
- ).toEqual({
- brand_ids: ["brand_1"],
- product_ids: ["prod_1"],
- })
- expect(BRAND_SEARCH_PROJECTION_EVENT_OPTIONS).toEqual({
- attempts: 5,
- backoff: {
- delay: 1000,
- type: "exponential",
- },
- })
- })
-
- it("expands changed Brands to their currently linked products", async () => {
- vi.stubEnv("MEILISEARCH_ENABLED", "1")
- const graph = vi.fn().mockResolvedValue({
- data: [{ product_id: "prod_linked" }, { product_id: "prod_explicit" }],
- })
- const container = asContainer((key) =>
- key === ContainerRegistrationKeys.QUERY ? { graph } : undefined
- )
-
- const targets = await resolveBrandSearchProjectionTargets(
- {
- brand_ids: ["brand_1", "brand_1"],
- product_ids: ["prod_explicit"],
- },
- container
- )
-
- expect(targets).toEqual({
- brand_ids: ["brand_1"],
- product_ids: ["prod_explicit", "prod_linked"],
- lock_keys: ["brand-search-projection"],
- })
- })
-
- it("upserts current active documents and deletes stale or non-published targets", async () => {
- vi.stubEnv("MEILISEARCH_ENABLED", "1")
- const graph = vi
- .fn()
- .mockResolvedValueOnce({
- data: [{ handle: "active", id: "brand_active", title: "Active" }],
- })
- .mockResolvedValueOnce({
- data: [
- { id: "prod_published", status: "published" },
- { id: "prod_draft", status: "draft" },
- ],
- })
- const meilisearch = {
- addDocuments: vi.fn().mockResolvedValue(undefined),
- deleteDocuments: vi.fn().mockResolvedValue(undefined),
- getFieldsForType: vi
- .fn()
- .mockResolvedValueOnce(["id", "title", "handle"])
- .mockResolvedValueOnce(["id", "status", "brand.id"]),
- getIndexesByType: vi
- .fn()
- .mockResolvedValueOnce(["brands"])
- .mockResolvedValueOnce(["products"]),
- }
- const container = asContainer((key) => {
- if (key === ContainerRegistrationKeys.QUERY) {
- return { graph }
- }
- if (key === "meilisearch") {
- return meilisearch
- }
- return
- })
-
- const result = await reconcileBrandSearchProjection(
- {
- brand_ids: ["brand_active", "brand_deleted"],
- lock_keys: [],
- product_ids: ["prod_published", "prod_draft", "prod_missing"],
- },
- container
- )
-
- expect(meilisearch.addDocuments).toHaveBeenNthCalledWith(
- 1,
- "brands",
- [
- {
- handle: "/store/brands/active/products",
- id: "brand_active",
- title: "Active",
- },
- ],
- "brands",
- { container }
- )
- expect(meilisearch.deleteDocuments).toHaveBeenNthCalledWith(1, "brands", [
- "brand_deleted",
- ])
- expect(meilisearch.addDocuments).toHaveBeenNthCalledWith(
- 2,
- "products",
- [{ id: "prod_published", status: "published" }],
- SearchUtils.indexTypes.PRODUCTS,
- { container }
- )
- expect(meilisearch.deleteDocuments).toHaveBeenNthCalledWith(2, "products", [
- "prod_draft",
- "prod_missing",
- ])
- expect(result).toEqual({
- brands_deleted: 1,
- brands_upserted: 1,
- products_deleted: 2,
- products_upserted: 1,
- })
- })
-
- it("does not resolve services when Meilisearch is disabled", async () => {
- vi.stubEnv("MEILISEARCH_ENABLED", "0")
- const resolve = vi.fn()
- const container = asContainer(resolve)
-
- await expect(
- reconcileBrandSearchProjection(
- {
- brand_ids: ["brand_1"],
- lock_keys: [],
- product_ids: ["prod_1"],
- },
- container
- )
- ).resolves.toEqual({
- brands_deleted: 0,
- brands_upserted: 0,
- products_deleted: 0,
- products_upserted: 0,
- })
- expect(resolve).not.toHaveBeenCalled()
- })
-})
diff --git a/apps/medusa-be/tests/unit/catalog/catalog-products-route.unit.spec.ts b/apps/medusa-be/tests/unit/catalog/catalog-products-route.unit.spec.ts
deleted file mode 100644
index 79cff41317..0000000000
--- a/apps/medusa-be/tests/unit/catalog/catalog-products-route.unit.spec.ts
+++ /dev/null
@@ -1,617 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-
-const { wrapProductsWithTaxPrices } = vi.hoisted(() => ({
- wrapProductsWithTaxPrices: vi.fn().mockResolvedValue(undefined),
-}))
-
-vi.mock("@medusajs/medusa/api/store/products/helpers", () => ({
- wrapProductsWithTaxPrices,
-}))
-
-import { GET } from "../../../src/api/store/catalog/products/route"
-
-type TestProduct = {
- id: string
- title: string
- status: "published" | "draft"
- salesChannelIds: string[]
- statusFacets?: string[]
- formFacets?: string[]
- brandFacets?: string[]
- ingredientFacets?: string[]
- facetPrice?: number
-}
-
-type GraphConfig = {
- entity: string
- fields?: string[]
- filters?: Record
-}
-
-type MeiliPriceRange = { max?: number; min?: number }
-
-const FACET_PRICE_MIN_FILTER_REGEX = /\bfacet_price >= ([0-9.]+)\b/
-const FACET_PRICE_MAX_FILTER_REGEX = /\bfacet_price <= ([0-9.]+)\b/
-
-const getFilterIds = (value: unknown): string[] => {
- if (Array.isArray(value)) {
- return value.filter((item): item is string => typeof item === "string")
- }
-
- if (typeof value === "string") {
- return [value]
- }
-
- if (value && typeof value === "object" && !Array.isArray(value)) {
- const inValue = (value as Record).$in
- if (Array.isArray(inValue)) {
- return inValue.filter((item): item is string => typeof item === "string")
- }
- }
-
- return []
-}
-
-const toArray = (value: unknown): string[] => {
- if (Array.isArray(value)) {
- return value.filter((item): item is string => typeof item === "string")
- }
-
- return typeof value === "string" ? [value] : []
-}
-
-const toFilterExpressions = (filter: unknown): unknown[] => {
- if (typeof filter === "string") {
- return [filter]
- }
-
- return Array.isArray(filter) ? filter : []
-}
-
-const getMeiliFilterValues = (filter: unknown, field: string): string[] => {
- const expressions = toFilterExpressions(filter)
-
- if (expressions.length === 0) {
- return []
- }
-
- const values: string[] = []
- const pattern = new RegExp(`${field} = "([^"]+)"`, "g")
- for (const expression of expressions) {
- if (typeof expression !== "string") {
- continue
- }
-
- for (const match of expression.matchAll(pattern)) {
- const value = match[1]
- if (value) {
- values.push(value)
- }
- }
- }
-
- return values
-}
-
-const getMeiliPriceRange = (filter: unknown): MeiliPriceRange => {
- const expressions = toFilterExpressions(filter)
-
- if (expressions.length === 0) {
- return {}
- }
-
- let min: number | undefined
- let max: number | undefined
- for (const expression of expressions) {
- if (typeof expression !== "string") {
- continue
- }
-
- const minMatch = expression.match(FACET_PRICE_MIN_FILTER_REGEX)
- if (minMatch?.[1]) {
- min = Number(minMatch[1])
- }
- const maxMatch = expression.match(FACET_PRICE_MAX_FILTER_REGEX)
- if (maxMatch?.[1]) {
- max = Number(maxMatch[1])
- }
- }
-
- return { min, max }
-}
-
-const productMatchesSearchFilters = (
- product: TestProduct,
- statusFilters: string[],
- salesChannelFilters: string[],
- priceRange: MeiliPriceRange
-) => {
- const matchesStatus =
- statusFilters.length === 0 || statusFilters.includes(product.status)
- const matchesSalesChannel =
- salesChannelFilters.length === 0 ||
- product.salesChannelIds.some((id) => salesChannelFilters.includes(id))
- const isAboveMin =
- priceRange.min === undefined ||
- (product.facetPrice !== undefined && product.facetPrice >= priceRange.min)
- const isBelowMax =
- priceRange.max === undefined ||
- (product.facetPrice !== undefined && product.facetPrice <= priceRange.max)
-
- return matchesStatus && matchesSalesChannel && isAboveMin && isBelowMax
-}
-
-const createMockResponse = () =>
- ({
- status: vi.fn().mockReturnThis(),
- json: vi.fn().mockReturnThis(),
- }) as any
-
-const createCatalogHarness = ({
- products,
- salesChannelId = "sc_visible",
- query = {},
-}: {
- products: TestProduct[]
- salesChannelId?: string
- query?: Record
-}) => {
- const productById = new Map(products.map((product) => [product.id, product]))
-
- const meiliSearch = vi.fn(
- async (
- _index: string,
- _query: string,
- options: {
- paginationOptions: {
- limit: number
- offset: number
- }
- additionalOptions?: {
- attributesToRetrieve?: string[]
- }
- filter?: string | string[]
- }
- ) => {
- const statusFilters = getMeiliFilterValues(
- options.filter,
- "facet_product_status"
- )
- const salesChannelFilters = getMeiliFilterValues(
- options.filter,
- "facet_sales_channel_ids"
- )
- const priceRange = getMeiliPriceRange(options.filter)
- const filteredProducts = products.filter((product) =>
- productMatchesSearchFilters(
- product,
- statusFilters,
- salesChannelFilters,
- priceRange
- )
- )
-
- const hits = filteredProducts
- .slice(
- options.paginationOptions.offset,
- options.paginationOptions.offset + options.paginationOptions.limit
- )
- .map((product) => ({ id: product.id }))
-
- const visiblePrices = filteredProducts
- .map((product) => product.facetPrice)
- .filter((price): price is number => typeof price === "number")
-
- const hiddenBrandCount = filteredProducts.filter((product) =>
- product.brandFacets?.includes("brand-hidden")
- ).length
- const visibleBrandCount = filteredProducts.filter((product) =>
- product.brandFacets?.includes("brand-visible")
- ).length
- const visibleIngredientCount = filteredProducts.filter((product) =>
- product.ingredientFacets?.includes("ingredient-visible")
- ).length
-
- return {
- hits,
- estimatedTotalHits: filteredProducts.length,
- facetDistribution: {
- facet_status: {
- action: filteredProducts.filter((product) =>
- product.statusFacets?.includes("action")
- ).length,
- "in-stock": filteredProducts.filter((product) =>
- product.statusFacets?.includes("in-stock")
- ).length,
- },
- facet_form: {},
- facet_brand: {
- ...(hiddenBrandCount > 0
- ? { "brand-hidden": hiddenBrandCount }
- : {}),
- ...(visibleBrandCount > 0
- ? { "brand-visible": visibleBrandCount }
- : {}),
- },
- facet_ingredient: {
- ...(visibleIngredientCount > 0
- ? { "ingredient-visible": visibleIngredientCount }
- : {}),
- },
- },
- facetStats: {
- facet_price: {
- min: visiblePrices.length ? Math.min(...visiblePrices) : undefined,
- max: visiblePrices.length ? Math.max(...visiblePrices) : undefined,
- },
- },
- }
- }
- )
-
- const queryGraph = vi.fn(async (config: GraphConfig) => {
- const filters = config.filters ?? {}
-
- if (config.entity === "product_sales_channel") {
- const productIds = getFilterIds(filters.product_id)
- const salesChannelIds = toArray(filters.sales_channel_id)
-
- return {
- data: productIds.flatMap((productId) => {
- const product = productById.get(productId)
- if (!product) {
- return []
- }
-
- const isLinked = product.salesChannelIds.some((id) =>
- salesChannelIds.includes(id)
- )
-
- return isLinked ? [{ product_id: product.id }] : []
- }),
- }
- }
-
- if (config.entity === "product") {
- const productIds = getFilterIds(filters.id)
- const status = filters.status
-
- return {
- data: productIds
- .map((productId) => productById.get(productId))
- .filter((product): product is TestProduct => Boolean(product))
- .filter((product) => !status || product.status === status)
- .map((product) => ({
- id: product.id,
- title: product.title,
- handle: product.id,
- thumbnail: null,
- metadata: {},
- variants: [],
- categories: [],
- brand: null,
- })),
- }
- }
-
- return { data: [] }
- })
-
- const req = {
- validatedQuery: {
- q: "",
- page: 1,
- limit: 12,
- sort: "recommended",
- ...query,
- },
- filterableFields: {
- sales_channel_id: [salesChannelId],
- status: "published",
- },
- queryConfig: {
- fields: [],
- },
- scope: {
- resolve: vi.fn((key: string) => {
- if (key === "meilisearch") {
- return { search: meiliSearch }
- }
- return { graph: queryGraph }
- }),
- },
- } as any
-
- const res = createMockResponse()
-
- return {
- req,
- res,
- meiliSearch,
- queryGraph,
- }
-}
-
-const getJsonPayload = (res: ReturnType) =>
- res.json.mock.calls[0][0]
-
-describe("GET /store/catalog/products", () => {
- const originalMeilisearchEnabled = process.env.MEILISEARCH_ENABLED
-
- beforeEach(() => {
- vi.clearAllMocks()
- process.env.MEILISEARCH_ENABLED = "1"
- })
-
- afterEach(() => {
- if (originalMeilisearchEnabled === undefined) {
- Reflect.deleteProperty(process.env, "MEILISEARCH_ENABLED")
- return
- }
-
- process.env.MEILISEARCH_ENABLED = originalMeilisearchEnabled
- })
-
- it("returns unavailable without loading Meilisearch when search is disabled", async () => {
- process.env.MEILISEARCH_ENABLED = "0"
- const { req, res, meiliSearch, queryGraph } = createCatalogHarness({
- products: [
- {
- id: "prod_visible",
- title: "Visible product",
- status: "published",
- salesChannelIds: ["sc_visible"],
- },
- ],
- })
-
- await GET(req, res)
-
- expect(res.status).toHaveBeenCalledWith(503)
- expect(res.json).toHaveBeenCalledWith({
- message: "Catalog search is disabled",
- })
- expect(req.scope.resolve).not.toHaveBeenCalled()
- expect(meiliSearch).not.toHaveBeenCalled()
- expect(queryGraph).not.toHaveBeenCalled()
- })
-
- it("excludes draft products from Meili hits", async () => {
- const { req, res } = createCatalogHarness({
- products: [
- {
- id: "prod_draft",
- title: "Draft product",
- status: "draft",
- salesChannelIds: ["sc_visible"],
- },
- ],
- })
-
- await GET(req, res)
-
- expect(getJsonPayload(res).products).toEqual([])
- expect(getJsonPayload(res).count).toBe(0)
- })
-
- it("excludes products outside the publishable-key sales channel", async () => {
- const { req, res } = createCatalogHarness({
- products: [
- {
- id: "prod_hidden_channel",
- title: "Hidden channel product",
- status: "published",
- salesChannelIds: ["sc_other"],
- },
- ],
- })
-
- await GET(req, res)
-
- expect(getJsonPayload(res).products).toEqual([])
- expect(getJsonPayload(res).count).toBe(0)
- })
-
- it("returns a published product linked to the visible sales channel", async () => {
- const { req, res } = createCatalogHarness({
- products: [
- {
- id: "prod_visible",
- title: "Visible product",
- status: "published",
- salesChannelIds: ["sc_visible"],
- },
- ],
- })
-
- await GET(req, res)
-
- expect(getJsonPayload(res).products).toEqual([
- expect.objectContaining({
- id: "prod_visible",
- title: "Visible product",
- }),
- ])
- expect(getJsonPayload(res).count).toBe(1)
- expect(wrapProductsWithTaxPrices).toHaveBeenCalledWith(
- req,
- getJsonPayload(res).products
- )
- })
-
- it("passes price filters to Meilisearch before applying visibility", async () => {
- const { req, res, meiliSearch } = createCatalogHarness({
- products: [
- {
- id: "prod_visible",
- title: "Visible product",
- status: "published",
- salesChannelIds: ["sc_visible"],
- facetPrice: 15,
- },
- ],
- query: {
- price_min: 10,
- price_max: 20,
- },
- })
-
- await GET(req, res)
-
- const searchOptions = meiliSearch.mock.calls[0]?.[2]
- expect(searchOptions?.filter).toContain("facet_price >= 10")
- expect(searchOptions?.filter).toContain("facet_price <= 20")
- expect(getJsonPayload(res).products).toHaveLength(1)
- })
-
- it("paginates and counts only visible products while preserving Meili order", async () => {
- const { req, res } = createCatalogHarness({
- products: [
- {
- id: "prod_hidden_channel",
- title: "Hidden channel product",
- status: "published",
- salesChannelIds: ["sc_other"],
- },
- {
- id: "prod_visible_1",
- title: "Visible product 1",
- status: "published",
- salesChannelIds: ["sc_visible"],
- },
- {
- id: "prod_draft",
- title: "Draft product",
- status: "draft",
- salesChannelIds: ["sc_visible"],
- },
- {
- id: "prod_visible_2",
- title: "Visible product 2",
- status: "published",
- salesChannelIds: ["sc_visible"],
- },
- {
- id: "prod_visible_3",
- title: "Visible product 3",
- status: "published",
- salesChannelIds: ["sc_visible"],
- },
- ],
- query: {
- page: 2,
- limit: 1,
- },
- })
-
- await GET(req, res)
-
- const payload = getJsonPayload(res)
- expect(
- payload.products.map((product: { id: string }) => product.id)
- ).toEqual(["prod_visible_2"])
- expect(payload.count).toBe(3)
- expect(payload.page).toBe(2)
- expect(payload.limit).toBe(1)
- expect(payload.totalPages).toBe(3)
- })
-
- it("uses indexed visibility filters instead of scanning all Meili hits", async () => {
- const products = Array.from({ length: 250 }, (_, index) => ({
- id: `prod_${index}`,
- title: `Product ${index}`,
- status: "published" as const,
- salesChannelIds: ["sc_visible"],
- }))
- const { req, res, meiliSearch } = createCatalogHarness({
- products,
- query: {
- limit: 12,
- },
- })
-
- await GET(req, res)
-
- expect(meiliSearch).toHaveBeenCalledTimes(1)
- const searchOptions = meiliSearch.mock.calls[0]?.[2]
- expect(searchOptions?.filter).toContain(
- 'facet_product_status = "published"'
- )
- expect(searchOptions?.filter).toContain(
- 'facet_sales_channel_ids = "sc_visible"'
- )
- expect(getJsonPayload(res).products).toHaveLength(12)
- expect(getJsonPayload(res).count).toBe(250)
- })
-
- it("derives facet counts and price stats only from visible products", async () => {
- const { req, res } = createCatalogHarness({
- products: [
- {
- id: "prod_hidden_channel",
- title: "Hidden channel product",
- status: "published",
- salesChannelIds: ["sc_other"],
- statusFacets: ["action"],
- formFacets: ["form-tablets"],
- brandFacets: ["brand-hidden"],
- ingredientFacets: ["ingredient-hidden"],
- facetPrice: 99,
- },
- {
- id: "prod_draft",
- title: "Draft product",
- status: "draft",
- salesChannelIds: ["sc_visible"],
- statusFacets: ["action"],
- formFacets: ["form-tablets"],
- brandFacets: ["brand-hidden"],
- ingredientFacets: ["ingredient-hidden"],
- facetPrice: 88,
- },
- {
- id: "prod_visible",
- title: "Visible product",
- status: "published",
- salesChannelIds: ["sc_visible"],
- statusFacets: ["in-stock"],
- formFacets: ["form-capsules"],
- brandFacets: ["brand-visible"],
- ingredientFacets: ["ingredient-visible"],
- facetPrice: 12,
- },
- ],
- })
-
- await GET(req, res)
-
- const payload = getJsonPayload(res)
- expect(
- payload.facets.status.find((item: { id: string }) => item.id === "action")
- .count
- ).toBe(0)
- expect(
- payload.facets.status.find(
- (item: { id: string }) => item.id === "in-stock"
- ).count
- ).toBe(1)
- expect(payload.facets.brand).toEqual([
- {
- id: "brand-visible",
- label: "visible",
- count: 1,
- },
- ])
- expect(payload.facets.ingredient).toEqual([
- {
- id: "ingredient-visible",
- label: "visible",
- count: 1,
- },
- ])
- expect(payload.facets.price).toEqual({
- min: 12,
- max: 12,
- })
- })
-})
diff --git a/apps/medusa-be/tests/unit/catalog/catalog-products-utils.unit.spec.ts b/apps/medusa-be/tests/unit/catalog/catalog-products-utils.unit.spec.ts
deleted file mode 100644
index 24b5483065..0000000000
--- a/apps/medusa-be/tests/unit/catalog/catalog-products-utils.unit.spec.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { describe, expect, it } from "vitest"
-
-import {
- buildCatalogFilterExpressions,
- normalizeBrandParam,
- normalizeCategoryIdsParam,
- normalizeFormParam,
- normalizeIngredientParam,
- normalizeStatusParam,
- resolveCatalogSort,
-} from "../../../src/api/store/catalog/products/utils"
-
-describe("catalog products filter utils", () => {
- it("normalizes multi-value query params", () => {
- expect(normalizeStatusParam("action,in-stock,unknown")).toEqual([
- "action",
- "in-stock",
- ])
-
- expect(
- normalizeFormParam(["form-capsules", "form-tablets,form-tablets"])
- ).toEqual(["form-capsules", "form-tablets"])
-
- expect(normalizeBrandParam("brand-natura,invalid")).toEqual([
- "brand-natura",
- ])
- expect(normalizeIngredientParam(["ingredient-horcik", "other"])).toEqual([
- "ingredient-horcik",
- ])
- expect(normalizeCategoryIdsParam("pcat_01,pcat_02")).toEqual([
- "pcat_01",
- "pcat_02",
- ])
- })
-
- it("builds meili filter expressions from normalized values", () => {
- const expressions = buildCatalogFilterExpressions({
- categoryIds: ["pcat_01", "pcat_02"],
- statusIds: ["action", "in-stock"],
- formIds: ["form-capsules"],
- brandIds: ["brand-natura"],
- ingredientIds: ["ingredient-horcik"],
- priceMin: 20,
- priceMax: 10,
- })
-
- expect(expressions).toEqual([
- '(facet_category_ids = "pcat_01" OR facet_category_ids = "pcat_02")',
- '(facet_status = "action" OR facet_status = "in-stock")',
- 'facet_form = "form-capsules"',
- 'facet_brand = "brand-natura"',
- 'facet_ingredient = "ingredient-horcik"',
- "facet_price >= 10",
- "facet_price <= 20",
- ])
- })
-
- it("maps API sort values to meili sort expressions", () => {
- expect(resolveCatalogSort("recommended")).toBeUndefined()
- expect(resolveCatalogSort("best-selling")).toBeUndefined()
- expect(resolveCatalogSort("newest")).toEqual(["created_at:desc"])
- expect(resolveCatalogSort("oldest")).toEqual(["created_at:asc"])
- expect(resolveCatalogSort("price-asc")).toEqual(["facet_price:asc"])
- expect(resolveCatalogSort("price-desc")).toEqual(["facet_price:desc"])
- expect(resolveCatalogSort("title-asc")).toEqual(["title:asc"])
- expect(resolveCatalogSort("title-desc")).toEqual(["title:desc"])
- })
-})
diff --git a/apps/medusa-be/tests/unit/catalog/product-facets.unit.spec.ts b/apps/medusa-be/tests/unit/catalog/product-facets.unit.spec.ts
deleted file mode 100644
index ff44ce74cf..0000000000
--- a/apps/medusa-be/tests/unit/catalog/product-facets.unit.spec.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { describe, expect, it } from "vitest"
-
-import { buildProductFacetDocument } from "../../../src/modules/meilisearch/facets/product-facets"
-
-describe("product facet document builder", () => {
- it("derives all primary facets from product payload", () => {
- const result = buildProductFacetDocument({
- status: "published",
- title: "Horčík kapsuly",
- metadata: {
- category_paths: [
- "Doplnky výživy > Účinné zložky od A po Z > Horčík",
- "Doplnky výživy > Forma > Kapsuly",
- ],
- flags: [
- {
- code: "action",
- active: true,
- },
- ],
- top_offer: {
- current_price: 15.9,
- stock: {
- amount: 2,
- },
- },
- },
- brand: {
- handle: "natura-balance",
- title: "Natura Balance",
- },
- categories: [
- {
- id: "pcat_01",
- handle: "ucinne-zlozky-od-a-po-z-horcik",
- name: "Horčík",
- },
- ],
- sales_channels: [
- {
- id: "sc_visible",
- },
- ],
- })
-
- expect(result.facet_product_status).toBe("published")
- expect(result.facet_sales_channel_ids).toEqual(["sc_visible"])
- expect(result.facet_status).toEqual(
- expect.arrayContaining(["in-stock", "action"])
- )
- expect(result.facet_form).toEqual(expect.arrayContaining(["form-capsules"]))
- expect(result.facet_brand).toEqual(["brand-natura-balance"])
- expect(result.facet_ingredient).toEqual([
- "ingredient-ucinne-zlozky-od-a-po-z-horcik",
- ])
- expect(result.facet_category_ids).toEqual(["pcat_01"])
- expect(result.facet_in_stock).toBe(true)
- expect(result.facet_price).toBe(15.9)
- })
-
- it("falls back to variant price and marks unavailable stock", () => {
- const result = buildProductFacetDocument({
- title: "Prírodný sirup",
- metadata: {
- top_offer: {
- stock: {
- amount: 0,
- },
- },
- },
- variants: [
- {
- prices: [
- {
- amount: 1290,
- currency_code: "eur",
- },
- ],
- },
- ],
- })
-
- expect(result.facet_in_stock).toBe(false)
- expect(result.facet_price).toBe(12.9)
- })
-
- it("parses localized decimal values from top offer metadata", () => {
- const result = buildProductFacetDocument({
- title: "Bylinné kvapky",
- metadata: {
- top_offer: {
- current_price: "19,81",
- stock: {
- amount: 4,
- },
- },
- },
- })
-
- expect(result.facet_price).toBe(19.81)
- })
-
- it("ignores non-positive top offer price and falls back to variant price", () => {
- const result = buildProductFacetDocument({
- title: "Pleťový olej",
- metadata: {
- top_offer: {
- current_price: 0,
- },
- },
- variants: [
- {
- prices: [
- {
- amount: 1990,
- currency_code: "eur",
- },
- ],
- },
- ],
- })
-
- expect(result.facet_price).toBe(19.9)
- })
-})
diff --git a/apps/medusa-be/tests/unit/src/config/medusa-config.unit.spec.ts b/apps/medusa-be/tests/unit/src/config/medusa-config.unit.spec.ts
index 75a03a6655..7f910e16dc 100644
--- a/apps/medusa-be/tests/unit/src/config/medusa-config.unit.spec.ts
+++ b/apps/medusa-be/tests/unit/src/config/medusa-config.unit.spec.ts
@@ -219,22 +219,6 @@ describe("readMedusaConfigEnv", () => {
])
})
- it("does not register Meilisearch when search is disabled", () => {
- const env = readMedusaConfigEnv({
- ...baseEnv,
- MEILISEARCH_ENABLED: "0",
- })
-
- expect(env.meilisearchHost).toBeUndefined()
- expect(buildPlugins(env)).not.toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- resolve: "@rokmohar/medusa-plugin-meilisearch",
- }),
- ])
- )
- })
-
it("keeps project CORS values separate", () => {
const env = readMedusaConfigEnv({
...baseEnv,
diff --git a/apps/new-engine-ctl/config/stack-inputs.yaml b/apps/new-engine-ctl/config/stack-inputs.yaml
index 9cb182a98d..aebdc76fc6 100644
--- a/apps/new-engine-ctl/config/stack-inputs.yaml
+++ b/apps/new-engine-ctl/config/stack-inputs.yaml
@@ -216,6 +216,7 @@ runtime_providers:
- documents.add
- documents.delete
- indexes.create
+ - indexes.delete
- indexes.get
- indexes.update
- indexes.swap
@@ -226,6 +227,11 @@ runtime_providers:
- products
- categories
- brands
+ - content
+ - product_*
+ - category_*
+ - brand_*
+ - content_*
- output_id: frontend_key
target_envs:
diff --git a/apps/payload/src/lib/hooks/medusa-cache.ts b/apps/payload/src/lib/hooks/medusa-cache.ts
index ac96a9495c..6298e45284 100644
--- a/apps/payload/src/lib/hooks/medusa-cache.ts
+++ b/apps/payload/src/lib/hooks/medusa-cache.ts
@@ -11,17 +11,15 @@ import { createRequestTimeout } from "../utils/request"
/** Payload invalidation payload sent to Medusa. */
type MedusaInvalidatePayload = {
collection: string
- doc?: {
- id?: string
- slug?: string
- locale?: string
- }
+ doc?: Record
+ operation?: string
}
/** Minimal CMS document shape for invalidation metadata. */
type CmsDoc = {
id?: string | number
slug?: string | Record
+ [key: string]: unknown
}
/** Track whether the missing base URL warning has already been logged. */
@@ -139,7 +137,14 @@ export const createMedusaCacheHook = (
id: cmsDoc?.id ? String(cmsDoc.id) : undefined,
slug: resolveSlug(cmsDoc, locale),
locale,
+ title: cmsDoc?.title,
+ excerpt: cmsDoc?.excerpt,
+ content: cmsDoc?.content,
+ contentHTML: cmsDoc?.contentHTML,
+ status: cmsDoc?.status,
+ visibility: cmsDoc?.visibility,
},
+ operation: op,
}
req?.payload?.logger?.info?.(
diff --git a/libs/storefront-data/src/catalog/medusa-service.ts b/libs/storefront-data/src/catalog/medusa-service.ts
index d62a2ff7d3..483a766c3f 100644
--- a/libs/storefront-data/src/catalog/medusa-service.ts
+++ b/libs/storefront-data/src/catalog/medusa-service.ts
@@ -38,6 +38,7 @@ export type MedusaCatalogServiceConfig<
listPath?: string
defaultLimit?: number
defaultSort?: string
+ queryDefaults?: MedusaCatalogListQuery
normalizeListQuery?: (params: TListParams) => MedusaCatalogListQuery
transformProduct?: (product: HttpTypes.StoreProduct) => TProduct
transformListProduct?: (
@@ -268,6 +269,7 @@ export function createMedusaCatalogService<
listPath = "/store/catalog/products",
defaultLimit = 12,
defaultSort = "recommended",
+ queryDefaults,
normalizeListQuery,
transformProduct,
transformListProduct,
@@ -287,10 +289,16 @@ export function createMedusaCatalogService<
const buildListQuery = (params: TListParams): MedusaCatalogListQuery => {
if (normalizeListQuery) {
- return stripNullishValues(normalizeListQuery(params))
+ return stripNullishValues({
+ ...queryDefaults,
+ ...normalizeListQuery(params),
+ })
}
- return buildDefaultListQuery(params, { defaultLimit, defaultSort })
+ return stripNullishValues({
+ ...queryDefaults,
+ ...buildDefaultListQuery(params, { defaultLimit, defaultSort }),
+ })
}
return {
diff --git a/patches/@rokmohar__medusa-plugin-meilisearch@1.4.3.patch b/patches/@rokmohar__medusa-plugin-meilisearch@1.4.3.patch
new file mode 100644
index 0000000000..103d2fc14b
--- /dev/null
+++ b/patches/@rokmohar__medusa-plugin-meilisearch@1.4.3.patch
@@ -0,0 +1,16 @@
+diff --git a/package.json b/package.json
+index a056975..d5bbcb2 100644
+--- a/package.json
++++ b/package.json
+@@ -22,11 +22,6 @@
+ "main": ".medusa/server/src/index.js",
+ "exports": {
+ "./package.json": "./package.json",
+- "./admin": {
+- "import": "./.medusa/server/src/admin/index.mjs",
+- "require": "./.medusa/server/src/admin/index.js",
+- "default": "./.medusa/server/src/admin/index.js"
+- },
+ "./workflows": "./.medusa/server/src/workflows/index.js",
+ "./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js",
+ ".": "./.medusa/server/src/modules/meilisearch/index.js"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fea8e55957..a4f2b3ccd2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -42,6 +42,7 @@ packageExtensionsChecksum: sha256-bfmqySvf7v98NPrRPcdoQkmEmUgdZs7NYgYf9oapspM=
patchedDependencies:
'@lexical/code@0.35.0': 38ef93d18a7c26b06c6c76116b49aa8fca1128da4eb27c3cda2b27d210788fd6
+ '@rokmohar/medusa-plugin-meilisearch@1.4.3': 2c4bd4dbbc19dd75662a8779e611e20b5726274443f18c32d678e388f8af56de
object-inspect@1.13.4: 901c1ec93cfdee21c7fbef4ed06bc68e8f176055c3c556d9a5ea9fabcb9cc0eb
importers:
@@ -388,7 +389,7 @@ importers:
version: 1.1.1(@types/react@19.2.3)(react@19.2.3)
'@rokmohar/medusa-plugin-meilisearch':
specifier: ^1.4.3
- version: 1.4.3(@medusajs/admin-sdk@2.18.0)(@medusajs/framework@2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3))(@medusajs/medusa@2.18.0)(@medusajs/ui@4.2.0(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@tanstack/react-query@5.64.2(react@19.2.3))(@types/node@24.12.4)(awilix@8.0.1)(express@4.22.2(supports-color@8.1.1))(mysql2@3.15.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@8.1.1)
+ version: 1.4.3(patch_hash=2c4bd4dbbc19dd75662a8779e611e20b5726274443f18c32d678e388f8af56de)(@medusajs/admin-sdk@2.18.0)(@medusajs/framework@2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3))(@medusajs/medusa@2.18.0)(@medusajs/ui@4.2.0(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@tanstack/react-query@5.64.2(react@19.2.3))(@types/node@24.12.4)(awilix@8.0.1)(express@4.22.2(supports-color@8.1.1))(mysql2@3.15.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@8.1.1)
'@sentry/node':
specifier: ^10.67.0
version: 10.67.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.220.0(@opentelemetry/api@1.9.1))(supports-color@8.1.1)
@@ -27231,7 +27232,7 @@ snapshots:
'@remix-run/router@1.23.3': {}
- '@rokmohar/medusa-plugin-meilisearch@1.4.3(@medusajs/admin-sdk@2.18.0)(@medusajs/framework@2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3))(@medusajs/medusa@2.18.0)(@medusajs/ui@4.2.0(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@tanstack/react-query@5.64.2(react@19.2.3))(@types/node@24.12.4)(awilix@8.0.1)(express@4.22.2(supports-color@8.1.1))(mysql2@3.15.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@8.1.1)':
+ '@rokmohar/medusa-plugin-meilisearch@1.4.3(patch_hash=2c4bd4dbbc19dd75662a8779e611e20b5726274443f18c32d678e388f8af56de)(@medusajs/admin-sdk@2.18.0)(@medusajs/framework@2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3))(@medusajs/medusa@2.18.0)(@medusajs/ui@4.2.0(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(@tanstack/react-query@5.64.2(react@19.2.3))(@types/node@24.12.4)(awilix@8.0.1)(express@4.22.2(supports-color@8.1.1))(mysql2@3.15.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@8.1.1)':
dependencies:
'@medusajs/admin-sdk': 2.18.0
'@medusajs/framework': 2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 0955ce482f..b0ca3dd413 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -132,6 +132,7 @@ packageExtensions:
patchedDependencies:
'@lexical/code@0.35.0': patches/@lexical__code@0.35.0.patch
+ '@rokmohar/medusa-plugin-meilisearch@1.4.3': patches/@rokmohar__medusa-plugin-meilisearch@1.4.3.patch
object-inspect@1.13.4: patches/object-inspect@1.13.4.patch
packages: