ADR 0006 — Motor de búsqueda y filtros facetados (Fase 5, Etapa 3)¶
Estado: aceptada — Etapa 2 en implementación (aprobada 2026-08-26 tras diagnóstico de memoria del host) Fecha: 2026-08-25
Contexto¶
ADR 0004 ya identificó un
motor de búsqueda dedicado (Meilisearch, autohospedado) como "el
siguiente paso natural" para ARI Shopping, pero lo dejó fuera del
alcance de Etapa 2 explícitamente hasta que el catálogo se acercara al
techo real de ~100 productos activos que impone
fetchProducts(filters, limit = 100) en medusa.ts.
Ese techo todavía no llegó — el catálogo real hoy son 26 productos
(17 legacy + 9 demo, ver ADR 0005
sección 5), lejos de 100. El disparador de esta propuesta no es el
techo de volumen sino la complejidad de facetas: la Etapa 2 ya
construyó una taxonomía real de 6 categorías raíz / 21 nodos
(navegación, mega-menú, breadcrumb — PRs #36-42) y un filtrado con
conteo por valor (categoryCounts/brandCounts/ageCounts en
catalog-client.tsx, PR #40) que hoy se calcula 100% client-side
sobre los productos ya traídos — el mismo patrón de "deuda de
alcance" que el hallazgo #4 de ADR 0004 ya señaló, ahora con más
dimensiones de filtro que multiplicar. Sigue siendo tratable a 26
productos; deja de serlo mucho antes de llegar a 100 si el catálogo
crece a la vez en número de productos y en dimensiones filtrables.
Restricción no negociable, igual que en toda la Fase 5: el flujo
Admin → evento product.updated → revalidate-storefront.ts →
revalidateTag("medusa-products"), que hoy mantiene el storefront
sincronizado sin intervención manual, no se toca. Cualquier índice de
búsqueda debe sincronizarse por el mismo mecanismo de eventos, nunca
por un cron ni un paso manual.
1. Estado real de la integración Medusa v2 + Meilisearch¶
Verificado contra la documentación oficial vigente el 2026-08-25, no contra memoria de entrenamiento:
- Medusa no tiene un módulo de búsqueda propio integrado con
Meilisearch hoy. La guía oficial
(
docs.medusajs.com/resources/integrations/guides/meilisearch) es un tutorial de módulo custom desde cero (npm install meilisearch+ workflows/subscribers propios) — no un paquete instalable. - Medusa sí introdujo un "Search Module" propio en la versión
2.19.0. El paquete comunitario
@rokmohar/medusa-plugin-meilisearch(el más usado y mantenido para Meilisearch+Medusa) se conecta a ese Search Module desde su versión^2.0.0, pero esa versión del plugin requiere Medusa^2.19.0. - Nuestro backend corre Medusa
2.18.0(commerce-backend/package.json) — justo por debajo de ese corte. La tabla de compatibilidad del plugin (github.com/rokmohar/medusa-plugin-meilisearch) resuelve esto: la línea^1.4.1soportaMedusa >= 2.15 < 2.19, servidor Meilisearch>= 1.5, Node>= 22. El propio repo lo explica: "The 2.19 release removed the search interface the v1 line was written against, so the two lines do not overlap: v1 stops at Medusa 2.18, v2 starts at 2.19." - Node de
commerce-backendya es22.18.0(Dockerfile) — cumple el mínimo del plugin sin cambios.
Implicación concreta: para adoptar Meilisearch sin subir de
versión Medusa (fuera de alcance de este ADR — sería su propia
migración), la pieza a instalar es @rokmohar/medusa-plugin-meilisearch@^1.4.1,
no la última versión del plugin. Esto es deuda técnica documentada,
no un obstáculo: si en el futuro se sube Medusa a 2.19+, migrar a la
línea ^2.0.0 del plugin (y al Search Module nativo) es el camino
recto.
2. Impacto de recursos¶
Estado real del host ZimaOS al momento de esta auditoría (free -h,
docker stats): 15 GiB RAM totales, swap al 100% (5.2/5.2 GiB
usados), solo 351 MiB realmente libres (6.5 GiB "available" vía
cache reclamable), 4 vCPU — la misma clase de contención que ya forzó
el fix de cpu_shares de Gitea. Cualquier servicio nuevo en este host
es una decisión de capacidad real, no teórica.
- Meilisearch no publica un mínimo de RAM oficial. Su propio FAQ
(
meilisearch.com/docs/resources/help/faq) solo dice que "más RAM implica búsquedas más rápidas" y recomienda ~10x el tamaño del dataset crudo en disco — sin piso duro. La cifra de "~1 GiB cómodo" que circula en blogs de la comunidad es una estimación, no una garantía del proveedor — se cita aquí como referencia, no como hecho verificado. - Meilisearch usa almacenamiento memory-mapped: el índice puede vivir parcialmente en disco cuando no entra en RAM, degradando latencia en vez de fallar. A la escala actual (26 productos, dataset de KB) el uso real de RAM del índice en sí es insignificante — el costo es el proceso base de Meilisearch, no el dataset.
- Comparación con Typesense (pedida explícitamente, mantenida
breve porque la diferencia relevante para este caso es clara y no
requiere más profundidad): Typesense mantiene el índice completo en
RAM (~30% más RAM que Meilisearch para el mismo dataset, según
benchmarks públicos) y, a diferencia de Meilisearch, no tiene
ningún plugin ni integración con Medusa — habría que escribir la
sincronización completa desde cero igual que con la guía "custom"
de Meilisearch, pero sin ninguna base comunitaria mantenida
compatible con nuestra versión de Medusa. Con Meilisearch ya
teniendo un plugin activo para
Medusa 2.18y menor huella de RAM, no hay motivo para evaluar Typesense más a fondo en este caso. - Recomendación: desplegar Meilisearch en k3d con
requests/limitsexplícitos y conservadores desde el día uno (ej.request: 256Mi/0.1 CPU,limit: 768Mi/0.5 CPU— a ajustar con datos reales de uso, no supuestos), y tratarlo como el mismo tipo de riesgo operativo que ya se vivió con Gitea: monitorear consumo real post-despliegue antes de darlo por estable. Dado el swap ya saturado, este es el punto de mayor incertidumbre de toda la propuesta — más que la elección de motor de búsqueda en sí.
3. Modelo de indexación¶
Basado en la taxonomía real ya viva en Medusa (Etapa 2) y en
Product/Category de lib/headless/types.ts:
| Campo | Buscable (texto, tolerante a typos) | Filtrable / faceta | Ordenable |
|---|---|---|---|
name |
Sí | No | No |
shortDescription / description |
Sí | No | No |
category (nombre de la hoja real) |
Sí | Sí (con conteo) | No |
collection |
Sí | Sí (con conteo) | No |
brand |
Sí | Sí (con conteo) | No |
ageRange |
No | Sí (con conteo) | No |
tags |
Sí | No | No |
price |
No | Sí (rango) | Sí |
compareAtPrice |
No | No | No (solo se usa para el badge, no como filtro) |
isNew / featured |
No | No | Sí (orden "Novedades"/"Destacados", igual que hoy) |
available |
No | Sí (booleano) | No |
slug, thumbnail, id |
No | No | No (solo se devuelven, no se indexan como facetas) |
La jerarquía de categorías (raíz/hija) no se indexa como un campo
aparte: el índice guarda el nombre de la hoja real tal como hoy lo
hace product.category, y el breadcrumb/mega-menú siguen resolviendo
la jerarquía contra /store/product-categories (sin cambios, ya
funciona desde la sub-fase 2). El motor de búsqueda resuelve
búsqueda de texto + conteo de facetas, no reemplaza la fuente de
la jerarquía.
4. Diseño de sincronización¶
Mismo patrón que revalidate-storefront.ts, no uno nuevo: un
subscriber adicional en commerce-backend/src/subscribers/ (ej.
sync-search-index.ts) que reacciona a eventos y le pide al plugin
que reindexe el documento afectado — nunca Medusa escribiendo
directamente en Meilisearch fuera de este canal.
Eventos a escuchar, confirmados en la documentación oficial de
eventos de Medusa (docs.medusajs.com/resources/events-reference):
product.created/product.updated/product.deletedproduct-variant.created/product-variant.updated/product-variant.deleted(mismo set que ya usarevalidate-storefront.ts, hoy validado en producción)- Nuevo respecto al subscriber actual:
product-category.created/product-category.updated/product-category.deleted— un cambio de nombre o jerarquía de categoría debe refrescar las facetas del índice, y hoyrevalidate-storefront.tsno escucha estos eventos porque el storefront los resuelve en cada request contra/store/product-categoriesdirectamente, sin necesidad de revalidación. El índice de búsqueda sí necesita enterarse.
Punto a verificar en vivo en Etapa 2, no asumido aquí: existe al
menos un reporte público de un evento product-collection.updated
que no se disparaba de forma confiable en ciertas versiones de Medusa
(github.com/medusajs/medusa/issues/11197). Antes de dar por cerrado
el diseño de sincronización hay que repetir exactamente la validación
que ya se hizo para revalidate-storefront.ts en la auditoría de
Etapa 1 de Fase 5: crear/editar una categoría real vía Admin y
confirmar en vivo que el evento llega al subscriber, no confiar en que
la documentación coincide con el comportamiento real de 2.18.0
(el mismo tipo de brecha que ya se encontró una vez con la relación
+categories de /store/products).
5. Diseño de UI — filtros facetados y búsqueda¶
Filtros facetados (sidebar de /catalog): mismo layout que ya
construyó la sub-fase 3 (catalog-client.tsx, PR #40) — sidebar con
"Categoría"/"Marca"/"Edad" como pills con conteo entre paréntesis y
slider de precio máximo. Lo único que cambia es de dónde sale el
conteo: hoy categoryCounts/brandCounts/ageCounts se calculan
recorriendo el arreglo de productos ya traído en el cliente; con el
motor de búsqueda, esos conteos vienen del propio Meilisearch
(facetDistribution) en la misma respuesta que trae los productos
filtrados, sin recorrer nada en el navegador. El breadcrumb no cambia
— sigue resolviéndose contra la jerarquía real de categorías, no
contra el índice de búsqueda.
Inicio > Mochilas y morrales > Marvel breadcrumb real (sin cambios)
┌────────────┬──────────────────────────────────────┐
│ Categoría │ [buscador con autocompletado] │ ← nuevo
│ ☐ Marvel(6)│ [card][card][card] │
│ ☐ Frozen(4)│ [card][card][card] │
│ Marca │ │
│ ☐ Disney(2)│ conteos ahora vienen de Meilisearch, │
│ Precio │ no de recorrer el arreglo en cliente │
│ [====○===] │ │
└────────────┴──────────────────────────────────────┘
Búsqueda con autocompletado (barra del navbar): hoy
PredictiveSearch (components/search/predictive-search.tsx) filtra
por substring sobre los productos ya cargados en memoria del cliente —
funciona a 26 productos, no escala y no tolera errores de tipeo (ADR
0004, hallazgo #5). Se reemplaza la fuente de datos del mismo
componente visual por searchProducts(query) del CatalogProvider
(interfaz que ya existe en lib/headless/types.ts, sin necesidad
de un tipo nuevo) apuntando a Meilisearch en vez de al ?q= de
Medusa: tolerancia a typos y ranking por relevancia nativos del motor,
mismo contrato de datos que ya consume el dropdown de resultados —
cero cambio de componente React, solo de qué hay detrás de
CatalogProvider.searchProducts.
Opciones consideradas¶
| Opción | A favor | En contra |
|---|---|---|
Meilisearch autohospedado (@rokmohar/medusa-plugin-meilisearch@^1.4.1, sincronizado por eventos) |
Plugin comunitario activo y compatible con nuestra versión exacta de Medusa (2.18); menor huella de RAM que la alternativa evaluada; almacenamiento memory-mapped se degrada en vez de fallar bajo presión de memoria; mismo patrón de subscriber que revalidate-storefront.ts, cero riesgo para ese flujo |
Ningún paquete "oficial" de Medusa (el plugin es comunitario, no del core); atado a la línea v1 del plugin mientras no se suba Medusa a 2.19+; un servicio más para operar en un host ya con swap saturado |
| Typesense autohospedado | Autocompletado ~2x más rápido según benchmarks públicos | Cero integración con Medusa (habría que escribir la sincronización completa desde cero, igual esfuerzo que la guía "custom" de Meilisearch pero sin base comunitaria); ~30% más RAM que Meilisearch para el mismo dataset, el peor trade-off posible dado el estado actual del host |
| Módulo custom desde cero (seguir la guía oficial de Medusa, sin el plugin de rokmohar) | Sin dependencia de un paquete de terceros; control total | Reimplementa lo que el plugin comunitario ya resuelve (declaración de índices, reindexado atómico, tracking de escrituras); más superficie propia para mantener sin ganancia clara sobre usar el plugin ya compatible con 2.18 |
| No adoptar motor de búsqueda todavía (seguir con filtrado 100% client-side) | Cero riesgo, cero componente nuevo | No resuelve la deuda de facetas ya identificada en ADR 0004 y agravada por la sub-fase 3; el techo de complejidad de filtros llega antes que el de 100 productos si el catálogo sigue creciendo en categorías |
Decisión¶
Meilisearch autohospedado, vía @rokmohar/medusa-plugin-meilisearch@^1.4.1
(no la última versión del plugin — esa exige Medusa 2.19+, fuera de
alcance), sincronizado por el mismo patrón de subscriber por eventos
que ya usa revalidate-storefront.ts, con product-category.* sumado
al set de eventos existente. Se despliega en k3d con límites de
recursos conservadores desde el primer día y se trata como
experimento a monitorear, no como capacidad garantizada, dado el
estado actual de memoria del host.
No se sube la versión de Medusa como parte de esta decisión —
queda como deuda técnica documentada para cuando se justifique
migrar a la línea v2 del plugin y al Search Module nativo de
Medusa.
Consecuencias¶
- Se gana: conteo de facetas real (no client-side), búsqueda tolerante
a errores de tipeo con ranking por relevancia, y una arquitectura
que ya no depende del techo de 100 productos de
fetchProducts. - Se acepta como deuda técnica: quedar atado a la línea
v1del plugin de Meilisearch mientras Medusa siga en2.18.x; migrar a Medusa2.19+es un proyecto aparte, no parte de esta Etapa 3. - Riesgo operativo real y no resuelto todavía por este ADR: el host ya corre con el swap saturado — el despliegue de Etapa 2 debe empezar con límites de recursos conservadores y validación en vivo de consumo real antes de confiar en el servicio, siguiendo la misma lección que dejó el incidente de recursos de Gitea.
- Revertir esta decisión implica desinstalar el plugin y el subscriber nuevo, y volver el buscador/filtrado a 100% client-side — no hay migración de datos que deshacer, porque Meilisearch nunca es la fuente de verdad, solo una copia de solo lectura indexada desde Medusa.
Nota de implementación (2026-08-26, antes de escribir código de Etapa 2)¶
Antes de tocar código se verificó el código fuente real de
@rokmohar/medusa-plugin-meilisearch en el tag exacto v1.4.1
(github.com/rokmohar/medusa-plugin-meilisearch), no su README
genérico ni memoria de entrenamiento — misma disciplina que ya destapó
el bug de +categories en /store/products. Aparecieron tres
desviaciones reales respecto al diseño de §4/§5 de este ADR:
- No se escribe un subscriber nuevo. El plugin trae sus propios
subscribers (
meilisearch-product-upsert.ts,meilisearch-category-upsert.ts, variantes/colección/inventario), auto-registrados al instalar el paquete — el propio README dice "You don't need custom subscribers anymore, you can remove them." Escribirsync-search-index.tscomo describe §4 causaría doble indexado. La escucha deproduct-category.*que pedía §4 ya viene incluida en los subscribers del plugin. - Las rutas Store API del plugin no devuelven
facetDistribution, pese a que §5 asume que sí. El servicio interno (MeiliSearchService.search()) sí puede pedirlo víaadditionalOptions: { facets: [...] }, pero las rutas empaquetadas nunca lo piden ni lo reenvían. Se agrega una ruta Store API propia (src/api/store/search/route.ts) que resuelve el servicio del plugin desde el container y arma{ products, facetDistribution }. Consecuencia positiva no buscada: Meilisearch queda 100%ClusterIP-only, sin Ingress, sin key pública ni ruta de red navegador→Meilisearch — todo pasa por Medusa. - El plugin se conecta vía un array
plugins:nuevo enmedusa-config.ts, no otra entrada enmodules:(el único precedente en este repo, el file-provider S3, usamodules:), y usa nombres de env var propios (MEILISEARCH_HOST/MEILISEARCH_API_KEY, leídos por el plugin) distintos del env var del servidor Meilisearch en sí (MEILI_MASTER_KEY, leído por el contenedorgetmeili/meilisearch) — mismo valor, dos nombres de secret.
Ninguna de las tres cambia la decisión ni sus consecuencias — cambian el cómo, no el qué. Plan de implementación completo, fase por fase, en la sesión que aprobó esta Etapa 2 (2026-08-26).
Nota de implementación 2 (2026-08-26, gate en vivo de Fase 2)¶
Dos bugs reales encontrados y corregidos durante el gate de
verificación en vivo (crear/editar productos, categoría y colección
reales y confirmar que llegan a Meilisearch), ambos con la misma causa
raíz: variants.calculated_price de Medusa exige region_id y
currency_code resueltos en el contexto de pricing de la consulta, y
ninguno de los dos caminos nuevos de este ADR pasa por el middleware
nativo (setPricingContext) que se lo da gratis a /store/products:
- El job de sync inicial del plugin (
sync-products.js, corre una vez al boot) hacía fallar el indexado de todos los productos conMethod calculatePrices requires currency_code in the pricing context— las categorías sí indexaban bien (no llevan precio). Arreglado indexando el precio crudo (variants.prices, sin resolver promociones/reglas) en vez decalculated_price— alcanza para el filtro/orden por precio de la búsqueda. - La ruta propia
/store/searchpegaba el mismo error al re-hidratar productos víaquery.graph. Pasarregion_idsolo no alcanzó; hubo que resolver elcurrency_codereal de la región con una consulta adicional antes de armar elQueryContext.
Confirmado en vivo, con el fix aplicado: búsqueda por texto, facetas
sobre las 27 unidades del catálogo real, y precio calculado correcto
(calculated_amount/currency_code) en la respuesta de
/store/search. También confirmado en vivo que los tres eventos que
el ADR original marcaba como "verificar, no asumir" — product.*,
product-category.* y product-collection.updated (el que tenía
un reporte público de no disparar siempre) — sí sincronizan de forma
confiable en esta combinación exacta (Medusa 2.18.0 + plugin 1.4.1),
propagando un rename de categoría/colección al documento indexado en
menos de 10 segundos.