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).
217 lines
6.9 KiB
TypeScript
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,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
})
|