Files
apps-registry/workloads/commerce-backend/medusa-config.ts
T
devops 6be8dd409c Fase 2 ADR 0006 Etapa 2: plugin de Meilisearch + ruta de facetas
Instala @rokmohar/medusa-plugin-meilisearch@^1.4.1 (linea v1, la unica
compatible con nuestro Medusa 2.18.0 exacto -- confirmado contra
peerDependencies reales de npm: @medusajs/* ^2.15.2, satisfecho por
2.18.0). Conectado via un array `plugins:` nuevo en medusa-config.ts
(primer uso en este repo, no `modules:`), env vars leidos con `?? ""`
en vez de required() -- si Meilisearch esta caido o mal configurado,
el plugin se degrada solo en vez de tumbar el arranque de Medusa
entero.

No se escribe un subscriber propio: se verifico contra el codigo
fuente real del paquete (descargado el tarball exacto de npm,
inspeccionado en /app/node_modules dentro de una imagen Docker de
verdad) que trae sus propios subscribers de product/category/
collection/variant/etc, descubiertos por Medusa en runtime desde
dentro del propio paquete -- confirmado que sobreviven a
`npm prune --omit=dev` y llegan intactos a la imagen final. Tambien
trae un job de indexado inicial de una sola corrida al boot
(numberOfExecutions: 1, no un cron recurrente -- no viola la
restriccion del ADR de "nunca cron").

Nueva ruta Store API propia (src/api/store/search/route.ts): las
rutas /store/meilisearch/* que trae el plugin no devuelven
facetDistribution (verificado leyendo su codigo fuente), asi que esta
ruta pide facets explicitamente via additionalOptions.facets, y
despues re-hidrata los productos via query.graph (mismos campos que
ya usa el storefront) en vez de confiar en precios/inventario
indexados -- mismo patron "safe path" que ya usa la ruta oficial del
plugin para /store/meilisearch/products.

Transformer custom en medusa-config.ts que espeja mapProduct() del
storefront (category/collection/brand/ageRange desde metadata,
disponibilidad desde la primera variante) para que el documento
indexado sea exactamente lo que el storefront ya muestra.

Validado en vivo: build completo de Docker (target builder) corre
limpio -- npm ci, medusa build (typecheck + compile backend/frontend),
npm prune -- cero errores. package-lock.json regenerado con un
contenedor node:22.18.0-bookworm-slim real (mismo base image que el
Dockerfile), no a mano.

Dockerfile: dummy MEILISEARCH_HOST/MEILISEARCH_API_KEY en el stage de
build (insurance barata, no son required() asi que no eran
estrictamente necesarios para que el build pasara, y de hecho paso
sin ellos en la prueba -- se dejan de todas formas por consistencia
con el resto de dummies de esa capa).
2026-08-26 15:39:30 -05:00

217 lines
6.9 KiB
TypeScript

import { defineConfig, loadEnv } from "@medusajs/framework/utils"
loadEnv(process.env.NODE_ENV || "development", process.cwd())
const required = (name: string): string => {
const value = process.env[name]
if (!value) {
throw new Error(`La variable ${name} es obligatoria.`)
}
return value
}
// Mismos campos derivados de metadata/categories/collection/variants que
// mapProduct() en workloads/ecommerce/lib/headless/providers/medusa.ts --
// el documento indexado debe reflejar exactamente lo que el storefront
// ya muestra, no una vista distinta del catálogo.
function stringMetadata(
metadata: Record<string, unknown> | null | undefined,
key: string,
): string {
const value = metadata?.[key]
return typeof value === "string" ? value : ""
}
function booleanMetadata(
metadata: Record<string, unknown> | null | undefined,
key: string,
): boolean {
const value = metadata?.[key]
return value === true || value === "true"
}
async function meilisearchProductTransformer(
document: Record<string, unknown>,
) {
const metadata = (document.metadata ?? null) as Record<
string,
unknown
> | null
const categories = document.categories as
| Array<{ name?: string | null }>
| null
| undefined
const collection = document.collection as { title?: string | null } | null
const variants = document.variants as
| Array<{
inventory_quantity?: number | null
manage_inventory?: boolean | null
allow_backorder?: boolean | null
calculated_price?: { calculated_amount?: number | null } | null
}>
| null
| undefined
const variant = variants?.[0]
const tags = document.tags as Array<{ value?: string | null }> | null
const category =
categories?.[0]?.name || stringMetadata(metadata, "category") || "Variedades"
const available =
!variant ||
variant.allow_backorder === true ||
variant.manage_inventory === false ||
(variant.inventory_quantity ?? 0) > 0
return {
id: document.id,
name: document.title || "Producto ARI Shopping",
shortDescription: document.subtitle || document.title || "",
description: document.description || "",
category,
collection: collection?.title || stringMetadata(metadata, "collection") || "",
brand: stringMetadata(metadata, "brand") || "ARI Shopping",
ageRange: stringMetadata(metadata, "ageRange") || "Todas",
tags: (tags || []).map((tag) => tag.value).filter(Boolean),
price:
typeof variant?.calculated_price?.calculated_amount === "number"
? variant.calculated_price.calculated_amount
: null,
available,
isNew: booleanMetadata(metadata, "isNew"),
featured: booleanMetadata(metadata, "featured"),
}
}
module.exports = defineConfig({
projectConfig: {
databaseUrl: required("DATABASE_URL"),
redisUrl: required("REDIS_URL"),
workerMode: (process.env.MEDUSA_WORKER_MODE || "shared") as
| "shared"
| "worker"
| "server",
http: {
storeCors: required("STORE_CORS"),
adminCors: required("ADMIN_CORS"),
authCors: required("AUTH_CORS"),
jwtSecret: required("JWT_SECRET"),
cookieSecret: required("COOKIE_SECRET"),
},
// Todos los Ingress del lab terminan en Traefik por HTTP plano
// (entrypoint "web", sin TLS); Cloudflare es quien atiende HTTPS de
// cara al navegador. Con `secure: true` (el default en producción),
// express-session nunca emite Set-Cookie porque no detecta la
// conexión como HTTPS, y el dashboard admin queda con 401 en
// /admin/users/me pese a loguear bien. La API con Bearer token no se
// ve afectada por esto.
cookieOptions: {
secure: false,
},
},
admin: {
disable: process.env.DISABLE_MEDUSA_ADMIN === "true",
backendUrl:
process.env.MEDUSA_BACKEND_URL ||
"https://commerce.cruzcloud.net",
storefrontUrl:
process.env.STOREFRONT_URL ||
"https://shop.cruzcloud.net",
},
modules: [
{
resolve: "@medusajs/medusa/file",
options: {
providers: [
{
resolve: "@medusajs/medusa/file-s3",
id: "s3",
options: {
file_url: required("S3_FILE_URL"),
access_key_id: required("S3_ACCESS_KEY_ID"),
secret_access_key: required("S3_SECRET_ACCESS_KEY"),
region: process.env.S3_REGION || "us-east-1",
bucket: required("S3_BUCKET"),
endpoint: required("S3_ENDPOINT"),
cache_control:
"public, max-age=31536000, immutable",
additional_client_config: {
forcePathStyle: true,
},
},
},
],
},
},
],
// ADR 0006 Etapa 2. Sin required(): a diferencia de S3/DB, Meilisearch
// es un servicio nuevo y todavia no probado en produccion en un host
// con historial de swap saturado -- si esta caido o mal configurado,
// el plugin se degrada solo (soporta enabled:false, "silently skipped"
// segun su propio README) en vez de tumbar el arranque de Medusa
// entero, que es lo que si haria required().
plugins: [
{
resolve: "@rokmohar/medusa-plugin-meilisearch",
options: {
config: {
host: process.env.MEILISEARCH_HOST ?? "",
apiKey: process.env.MEILISEARCH_API_KEY ?? "",
},
settings: {
products: {
type: "products",
enabled: Boolean(process.env.MEILISEARCH_HOST),
primaryKey: "id",
// Campos que el plugin pide via query.graph antes de pasarlos
// al transformer de arriba -- si falta alguno acá, llega
// undefined al transformer sin importar lo que este haga.
fields: [
"id",
"title",
"subtitle",
"description",
"handle",
"status",
"thumbnail",
"metadata",
"tags.value",
"categories.name",
"collection.title",
"variants.inventory_quantity",
"variants.manage_inventory",
"variants.allow_backorder",
"variants.calculated_price.calculated_amount",
],
indexSettings: {
// Tabla de ADR 0006 §3: Buscable / Filtrable-faceta / Ordenable.
searchableAttributes: [
"name",
"shortDescription",
"description",
"category",
"collection",
"brand",
"tags",
],
filterableAttributes: [
"category",
"collection",
"brand",
"ageRange",
"price",
"available",
],
sortableAttributes: ["price", "isNew", "featured"],
},
transformer: meilisearchProductTransformer,
},
},
},
},
],
})