Files
apps-registry/workloads/docs-portal/docs/decisiones/0006-motor-busqueda-facetas.md
T

19 KiB

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.1 soporta Medusa >= 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-backend ya es 22.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.18 y 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/limits explí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.deleted
  • product-variant.created / product-variant.updated / product-variant.deleted (mismo set que ya usa revalidate-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 hoy revalidate-storefront.ts no escucha estos eventos porque el storefront los resuelve en cada request contra /store/product-categories directamente, 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 v1 del plugin de Meilisearch mientras Medusa siga en 2.18.x; migrar a Medusa 2.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:

  1. 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." Escribir sync-search-index.ts como describe §4 causaría doble indexado. La escucha de product-category.* que pedía §4 ya viene incluida en los subscribers del plugin.
  2. 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ía additionalOptions: { 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.
  3. El plugin se conecta vía un array plugins: nuevo en medusa-config.ts, no otra entrada en modules: (el único precedente en este repo, el file-provider S3, usa modules:), 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 contenedor getmeili/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:

  1. El job de sync inicial del plugin (sync-products.js, corre una vez al boot) hacía fallar el indexado de todos los productos con Method 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 de calculated_price — alcanza para el filtro/orden por precio de la búsqueda.
  2. La ruta propia /store/search pegaba el mismo error al re-hidratar productos vía query.graph. Pasar region_id solo no alcanzó; hubo que resolver el currency_code real de la región con una consulta adicional antes de armar el QueryContext.

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.