[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"portal-settings:stajic:es":3,"public-menus:all":38,"post:canonical-architecture-url-design-resolver-logic-api-scalability-specification:es":205,"related:post:canonical-architecture-url-design-resolver-logic-api-scalability-specification:es:1":910},{"statusCode":4,"data":5,"message":37},200,{"tenantId":6,"lang":7,"defaultLang":8,"siteUrl":9,"contactEmail":10,"brandName":11,"logoUrl":12,"siteName":11,"siteDescription":13,"ogImage":10,"robotsIndex":14,"socialLinks":10,"reservedSlugs":10,"seoPolicy":15},"stajic","es","de","https:\u002F\u002Fstajic.de",null,"Stajic Platform","\u002FLogo_Planet.svg","Stajic Portal",true,{"branding":16,"relatedContent":17,"crossDomainLinks":18},{"logoUrl":12},{"enabled":14},[19,22,25,28,31,34],{"url":20,"label":21,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Ffigure.rocks","figure.rocks",{"url":23,"label":24,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Floving.rocks","loving.rocks",{"url":26,"label":27,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.com","bazify.com",{"url":29,"label":30,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.de","bazify.de",{"url":32,"label":33,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.at","bazify.at",{"url":35,"label":36,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.ba","bazify.ba","Portal settings resolved",[39,45],{"id":40,"name":41,"location":42,"isActive":14,"isDefault":43,"items":44},1,"main-navigation","header",false,[],{"id":46,"name":47,"location":48,"isActive":14,"isDefault":14,"items":49},4,"main-menu","sidebar",[50,66,79,93,103,118,133],{"id":51,"title":52,"url":60,"target":61,"icon":62,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":64,"portfolioId":10,"children":65},"item-18",{"de":53,"en":54,"es":55,"fr":56,"it":54,"ru":57,"sr":58,"zh":59},"Startseite","Home","Inicio","Accueil","Главная","Почетна","首页","\u002Ffull-stack-web-developer-munich-performance-seo-and-maintainable-builds","_self","i-lucide-home","page",111,[],{"id":67,"title":68,"url":75,"target":61,"icon":76,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":77,"portfolioId":10,"children":78},"item-22",{"de":69,"en":69,"es":70,"fr":69,"it":71,"ru":72,"sr":73,"zh":74},"Vision","Visión","Visione","Видение","Визија","想象","\u002Fueber-uns-webdesign-muenchen-webaplikation","i-lucide-eye",113,[],{"id":80,"title":81,"url":89,"target":61,"icon":90,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":91,"portfolioId":10,"children":92},"item-19",{"de":82,"en":83,"es":84,"fr":83,"it":85,"ru":86,"sr":87,"zh":88},"Leistungen","Services","Servicios","Servizi","Услуги","Услуге","服务","\u002Fservices-dienstleistungen-muenchen","i-lucide-wrench",116,[],{"id":94,"title":95,"url":99,"target":61,"icon":100,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":101,"portfolioId":10,"children":102},"item-23",{"de":96,"en":96,"es":96,"fr":96,"it":96,"ru":97,"sr":97,"zh":98},"Blog","Блог","博客","\u002Fblog","i-lucide-book-open",112,[],{"id":104,"title":105,"url":114,"target":61,"icon":115,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":116,"portfolioId":10,"children":117},"item-32",{"de":106,"en":107,"es":108,"fr":109,"it":110,"ru":111,"sr":112,"zh":113},"Neue Technologien","New Technologies","Nuevas tecnologías","Nouvelles technologies","Nuove tecnologie","Новые технологии","Нове технологије","新技术！","\u002Fneue-webtechnologien","i-lucide-sparkles",122,[],{"id":119,"title":120,"url":129,"target":61,"icon":130,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":131,"portfolioId":10,"children":132},"item-20",{"de":121,"en":122,"es":123,"fr":124,"it":125,"ru":126,"sr":127,"zh":128},"Kontakt","Contact us!","Contacto","Contact","Contatto","Контакт","Контактирајте нас","联系我们！","\u002Fcontact","i-lucide-mail",115,[],{"id":134,"title":135,"url":144,"target":61,"icon":145,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":146,"portfolioId":10,"children":147},"item-21",{"de":136,"en":137,"es":138,"fr":139,"it":140,"ru":141,"sr":142,"zh":143},"Unsere Arbeit","Our Work","Nuestro trabajo","Nos réalisations","I nostri lavori","Наши работы","Наши радови","文件夹","\u002Fportfolio","i-lucide-briefcase",114,[148,161,175,181,193],{"id":149,"title":150,"url":144,"target":61,"icon":159,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":146,"portfolioId":10,"children":160},"item-24",{"de":151,"en":152,"es":153,"fr":154,"it":155,"ru":156,"sr":157,"zh":158},"Alle Projekte","All Projects","Todos los proyectos","Tous les projets","Tutti i progetti","Все проекты","Сви пројекти","所有项目","i-lucide-grid-3x3",[],{"id":162,"title":163,"url":171,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":174},"item-29",{"de":164,"en":165,"es":166,"fr":167,"it":168,"ru":169,"sr":170,"zh":143},"Local Roots, Global Reach","Local Roots - Global Reach","Empresa local ","Entreprise locale","Azienda locale","Местная компания","Локално предузеће глобално тржиште","\u002Fportfolio\u002Flocal-roots-global-reach-communication-media-systems-for-modern-business","i-lucide-folder","custom",[],{"id":176,"title":177,"url":179,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":180},"item-28",{"de":178,"en":178,"es":178,"fr":178,"it":178,"ru":178,"sr":178,"zh":178},"Solr Suggester","\u002Fportfolio\u002Fsolr-fuzzy-suggester-und-solr-infix-suggester-abfrage-ueber-ajax-und-filterung",[],{"id":182,"title":183,"url":191,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":192},"item-27",{"de":184,"en":185,"es":186,"fr":187,"it":188,"ru":189,"sr":190,"zh":185},"Firmenwebseite SEO","Company Website SEO","Sitio web corporativo SEO","Site web d’entreprise SEO","Sito web aziendale SEO","Корпоративный сайт SEO","Пословна веб-страница SEO","\u002Fportfolio\u002Fseo-sem-branding-mobile-webseite-muenchen",[],{"id":194,"title":195,"url":203,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":204},"item-31",{"de":196,"en":197,"es":198,"fr":199,"it":200,"ru":201,"sr":202,"zh":197},"Digitalisierungsportal","Digitalization Portal","Portal de digitalización","Portail de numérisation","Portale di digitalizzazione","Портал цифровизации","Портал за дигитализацију","\u002Fportfolio\u002Fdigitalisierungsportal-archiv-museum-bibliothek-ead-lido-mets-mods",[],{"statusCode":4,"data":206,"message":909},{"id":207,"title":208,"slug":209,"content":210,"contentJson":211,"excerpt":510,"featuredImage":511,"featuredImageAlt":512,"featuredImageCaption":10,"featuredImageTitle":10,"featuredImageCopyright":10,"featuredImageAuthor":10,"featuredImageSourceUrl":10,"featuredImageLicense":10,"featuredImageIsAiGenerated":43,"status":513,"publishedAt":514,"createdAt":515,"updatedAt":516,"seoLocalePaths":517,"categories":526,"author":547,"translations":552},"383","Arquitectura Canónica, Diseño de URL, Lógica del Resolvedor, Especificación de API y Escalabilidad","canonical-architecture-url-design-resolver-logic-api-scalability-specification","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1\",\"data\":{\"text\":\"Geo Discovery: arquitectura canónica, diseño de URL, lógica del resolver, API y especificación de escalabilidad\",\"level\":1},\"type\":\"header\"},{\"id\":\"p-scope\",\"data\":{\"text\":\"Este documento especifica la superficie de descubrimiento geográfico (país\u002Fciudad\u002Fradio) a través de múltiples portales (multi-tenant), sin forzar una refactorización inmediata de la base de datos y sin acoplarse al enrutamiento del CMS (página\u002Fpost). Mantiene la estabilidad SEO, es compatible con la caché y deja espacio para reservas, reseñas y mapas sin convertir la aplicación en un bloque monolítico.\"},\"type\":\"paragraph\"},{\"id\":\"h-goals\",\"data\":{\"text\":\"Restricciones y no-objetivos\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-constraints\",\"data\":{\"items\":[\"Multi-tenant: la misma base de código sirve a múltiples portales. El tenant afecta el alcance del contenido, la identidad visual y, en ocasiones, la fuente de datos.\",\"El descubrimiento geográfico debe soportar: país, ciudad y búsqueda por radio (alrededor de un punto).\",\"Sin refactorización inmediata de la base de datos: no podemos reestructurar las tablas existentes en un esquema geo ideal en este momento.\",\"Independencia del enrutamiento del CMS: las páginas geo no son “posts” ni “páginas”. No pueden ser bloqueadas por conflictos de slug del CMS.\",\"Estabilidad SEO: las URL canónicas no deben cambiar cuando cambian los filtros u opciones de ordenación.\",\"Compatibilidad con caché: el CDN y la caché del servidor deben tener claves predecibles. Evitar variaciones por usuario.\",\"Separación estricta de responsabilidades: discovery, CMS y resolución del tenant son módulos separados con límites explícitos.\",\"No-objetivo (por ahora): geocodificación perfecta. Aceptamos un solo geocoder, una estrategia de normalización y almacenamos el resultado normalizado.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-arch\",\"data\":{\"text\":\"Arquitectura canónica\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-arch\",\"data\":{\"text\":\"Implementamos el descubrimiento geográfico como un bounded context autónomo con una superficie pública mínima: (1) URL → Resolver, (2) Resolver → Query Plan, (3) Query Plan → Data Providers, (4) Respuesta → metadatos SEO y de caché. Las rutas del CMS nunca llaman a discovery. Discovery nunca llama al enrutamiento del CMS. Solo comparten utilidades de bajo nivel (HTTP, caché, contexto de tenant).\"},\"type\":\"paragraph\"},{\"id\":\"h-components\",\"data\":{\"text\":\"Componentes clave\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-components\",\"data\":{\"items\":[\"TenantContext: resuelve el tenant a partir del encabezado Host (o de un identificador de portal explícito en llamadas internas).\",\"GeoResolver: analiza y valida los segmentos de URL geográficos; emite un GeoQuery normalizado.\",\"GeoIndex (Read Model): tabla o colección separada que mapea entityId → lat\u002Flng + scope del tenant + campos mínimos buscables. Esto evita refactorizar las tablas de la base de datos origen.\",\"Data Providers: fuentes intercambiables (por ejemplo, tablas SQL existentes, API de WordPress, otro servicio de portal), encapsuladas detrás de una interfaz.\",\"SEO Router: genera la URL canónica y los metadatos (canonical, hreflang si es necesario, flags robots).\",\"Capa de caché: claves de caché del CDN y caché del servidor con claves tenant-scoped y versionado.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-folder\",\"data\":{\"text\":\"Estructura de carpetas\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-folder\",\"data\":{\"code\":\"apps\u002F\\n  web\u002F\\n    routes\u002F\\n      discovery\u002F                  # discovery entry points (not CMS)\\n    pages\u002F\\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\\n  server\u002F\\n    src\u002F\\n      tenant\u002F\\n        TenantContext.ts\\n        tenantConfig.ts\\n      discovery\u002F\\n        geo\u002F\\n          GeoResolver.ts\\n          GeoQuery.ts\\n          GeoCanonical.ts\\n          GeoController.ts\\n          providers\u002F\\n            GeoProvider.ts\\n            SqlGeoProvider.ts\\n            RemoteGeoProvider.ts\\n          index\u002F\\n            GeoIndexRepository.ts\\n            migrations\u002F\\n      cache\u002F\\n        Cache.ts\\n        cacheKeys.ts\\n      http\u002F\\n        errors.ts\\n        requestContext.ts\\npackages\u002F\\n  shared\u002F\\n    src\u002F\\n      geo\u002F\\n        normalize.ts\\n        haversine.ts\\n      validation\u002F\\n        zod.ts\\n\",\"language\":\"text\"},\"type\":\"code\"},{\"id\":\"h-url\",\"data\":{\"text\":\"Diseño de URL (SEO Canonical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-url\",\"data\":{\"text\":\"Mantenemos la URL canónica puramente jerárquica y legible para humanos. Los filtros y la ordenación permanecen en la querystring, pero NO afectan al canonical. La búsqueda por radio utiliza una página “near” estable con un canonical basado en una celda de coordenadas redondeada, no en lat\u002Flng en bruto. Esto evita variantes infinitas de URL y previene la explosión de la caché. Es un compromiso deliberado.\"},\"type\":\"paragraph\"},{\"id\":\"list-url\",\"data\":{\"items\":[\"Landing de país: \u002Fdiscover\u002F{countryCode} (ejemplo: \u002Fdiscover\u002Fde)\",\"Landing de ciudad: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (ejemplo: \u002Fdiscover\u002Fde\u002Fmunich)\",\"Near (radio): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (ejemplo: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z), donde cellId es un identificador corto tipo geohash\",\"Parámetros de consulta opcionales (no canónicos): ?q=shih-tzu&category=pet-care&sort=rating&page=2\",\"El tenant NO está en la ruta. El tenant es el host (portal-a.tld, portal-b.tld). Las llamadas internas envían x-tenant-id.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-canonical-rules\",\"data\":{\"text\":\"Reglas de canonicalización\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-canonical\",\"data\":{\"items\":[\"Las páginas de país\u002Fciudad canonizan hacia su propia ruta limpia (sin querystring).\",\"Las páginas near canonizan a \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. El canonical se deriva de una celda redondeada, no de las coordenadas entrantes.\",\"page=1 se omite del canonical y de la generación de enlaces internos.\",\"Las combinaciones no soportadas (por ejemplo, desajuste de país) devuelven 404, no una redirección. Las cadenas de redirección perjudicaban el presupuesto de rastreo en las pruebas.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-resolver\",\"data\":{\"text\":\"Lógica del resolver\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-resolver\",\"data\":{\"text\":\"La entrada del resolver es (tenant, pathname, query). La salida es un GeoQuery normalizado con un query plan. No hay acceso a base de datos dentro del resolver. Esta separación fue crucial más adelante: nos salvó de un bug de caché bastante desagradable.\"},\"type\":\"paragraph\"},{\"id\":\"code-types\",\"data\":{\"code\":\"export type TenantId = string;\\n\\nexport type GeoScope =\\n  | { kind: 'country'; countryCode: string }\\n  | { kind: 'city'; countryCode: string; citySlug: string }\\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\\n\\nexport type GeoFilters = {\\n  q?: string;\\n  category?: string;\\n  sort?: 'relevance' | 'rating' | 'distance';\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport type GeoQuery = {\\n  tenantId: TenantId;\\n  scope: GeoScope;\\n  filters: GeoFilters;\\n  canonicalPath: string;\\n  cacheKey: string;\\n};\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"code-resolver\",\"data\":{\"code\":\"import { z } from 'zod';\\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\\n\\nconst QuerySchema = z.object({\\n  q: z.string().trim().min(1).max(120).optional(),\\n  category: z.string().trim().min(1).max(60).optional(),\\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\\n  page: z.coerce.number().int().min(1).max(200).default(1),\\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\\n});\\n\\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\\n\\nexport function resolveGeo(\\n  tenantId: TenantId,\\n  pathname: string,\\n  query: Record\u003Cstring, unknown>\\n): GeoQuery {\\n  const filters = QuerySchema.parse(query);\\n  const parts = pathname.split('\u002F').filter(Boolean);\\n\\n  if (parts[0] !== 'discover') {\\n    throw new Error('Not a discovery route');\\n  }\\n\\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\\n\\n  let scope: GeoQuery['scope'];\\n  if (parts.length === 2) {\\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\\n  } else if (parts[2] === 'near') {\\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\\n  } else {\\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\\n  }\\n\\n  const canonicalPath = canonicalizePath(scope);\\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\\n\\n  return {\\n    tenantId,\\n    scope,\\n    filters: { ...filters, sort: filters.sort ?? 'relevance' },\\n    canonicalPath,\\n    cacheKey\\n  };\\n}\\n\\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\\n  switch (scope.kind) {\\n    case 'country': return `\u002Fdiscover\u002F${scope.countryCode}`;\\n    case 'city': return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\\n    case 'near': return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\\n  }\\n}\\n\\nfunction buildCacheKey(\\n  tenantId: string,\\n  scope: GeoQuery['scope'],\\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\\n): string {\\n  const q = filters.q ? `q=${filters.q}` : '';\\n  const c = filters.category ? `cat=${filters.category}` : '';\\n  const s = `sort=${filters.sort ?? 'relevance'}`;\\n  const p = `p=${filters.page}`;\\n  const ps = `ps=${filters.pageSize}`;\\n\\n  const scopeKey =\\n    scope.kind === 'country'\\n      ? `country:${scope.countryCode}`\\n      : scope.kind === 'city'\\n        ? `city:${scope.countryCode}:${scope.citySlug}`\\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\\n\\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-flow\",\"data\":{\"text\":\"Flujo de solicitud paso a paso\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-flow\",\"data\":{\"items\":[\"El Edge\u002FCDN recibe la solicitud. La clave de caché incluye el host + la ruta + parámetros de consulta acotados (q, category, sort, page, pageSize, r).\",\"El servidor de la aplicación crea el TenantContext a partir del encabezado Host. Sin acceso a base de datos todavía.\",\"El GeoResolver analiza la URL y produce un GeoQuery con canonicalPath y cacheKey del servidor.\",\"El GeoController construye un QueryPlan y decide qué provider(s) usar según la configuración del tenant y el tipo de scope.\",\"El provider ejecuta una consulta del read-model contra el GeoIndex (rápida) y luego hidrata los resultados desde la fuente existente (DB SQL, API CMS) usando los entity IDs.\",\"El ensamblador de la respuesta adjunta metadatos SEO: canonical, robots y enlaces de paginación.\",\"El servidor establece encabezados de caché (s-maxage + stale-while-revalidate). El cuerpo está acotado por tenant y nunca se comparte entre tenants.\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-read-model\",\"data\":{\"text\":\"Estrategia sin refactorización: Read Model GeoIndex\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-read-model\",\"data\":{\"text\":\"No podemos reestructurar las tablas de contenido existentes de inmediato. Por ello añadimos un GeoIndex separado que puede reconstruirse de forma independiente. Almacena solo lo necesario para una discovery eficiente: tenantId, entityId, entityType, countryCode, citySlug, lat, lng y algunos campos de filtrado. La hidratación obtiene el objeto completo desde la fuente actual.\"},\"type\":\"paragraph\"},{\"id\":\"p-tradeoffs-read-model\",\"data\":{\"text\":\"Compromiso: consistencia eventual. El retraso en la reconstrucción del índice es aceptable para las páginas de discovery. SLA: las actualizaciones aparecen en un plazo de 15 minutos. Si más adelante necesitamos tiempo real (disponibilidad de reservas), esa será otra superficie y no debe reutilizar la caché de discovery.\"},\"type\":\"paragraph\"},{\"id\":\"h-api\",\"data\":{\"text\":\"Superficie de API\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-api\",\"data\":{\"text\":\"Dos superficies: (A) páginas HTML para SEO y usuarios, (B) API JSON para renderizado del cliente y extensiones futuras. Mismo resolver y mismo query plan. Presentadores distintos.\"},\"type\":\"paragraph\"},{\"id\":\"list-api\",\"data\":{\"items\":[\"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\",\"API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..\",\"Admin (interno): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protegido), POST \u002Finternal\u002Fgeoindex\u002Fupsert (opcional, más adelante)\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"code-api-handler\",\"data\":{\"code\":\"import type { IncomingMessage, ServerResponse } from 'http';\\nimport { resolveGeo } from '.\u002FGeoResolver';\\nimport { runQueryPlan } from '.\u002FGeoController';\\n\\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\\n\\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\\n\\n  const scope = url.searchParams.get('scope') ?? 'country';\\n  const country = url.searchParams.get('country') ?? '';\\n  const city = url.searchParams.get('city');\\n  const cell = url.searchParams.get('cell');\\n\\n  const pseudoPath =\\n    scope === 'city' && city\\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\\n      : scope === 'near' && cell\\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\\n        : `\u002Fdiscover\u002F${country}`;\\n\\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\\n  const result = await runQueryPlan(geoQuery);\\n\\n  res.statusCode = 200;\\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\\n\\n  res.end(JSON.stringify(result));\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-scalability\",\"data\":{\"text\":\"Escalabilidad y rendimiento\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-scale\",\"data\":{\"items\":[\"Objetivo principal de rendimiento: servir el HTML de discovery desde el CDN para los landings geo populares (país\u002Fciudad).\",\"Las páginas near son cacheables pero tienen más variantes (cellId + r + q + category + sort + page). Acotamos r y pageSize y validamos estrictamente los filtros.\",\"La consulta al GeoIndex debe ser rápida: usar índices por tenantId + countryCode + citySlug; para near usar buckets de celdas (coincidencia por prefijo) y refinar la distancia en la aplicación.\",\"Evitar cálculos haversine SQL costosos sobre grandes conjuntos de datos. Parece simple. No lo es.\",\"La hidratación se realiza por lotes según la lista de entityId: una consulta por entityType, no N+1.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-near-strategy\",\"data\":{\"text\":\"Estrategia de búsqueda por radio (páginas near)\",\"level\":3},\"type\":\"header\"},{\"id\":\"p-near\",\"data\":{\"text\":\"No ejecutamos un escaneo de radio completo sobre todas las filas. En su lugar: (1) el cellId mapea a un bucket delimitador (prefijo tipo geohash), (2) se obtienen candidatos del GeoIndex por prefijo de bucket, (3) se refina en la aplicación usando distancia haversine, (4) se ordena y pagina. Esto mantiene la carga de la base de datos predecible.\"},\"type\":\"paragraph\"},{\"id\":\"code-haversine\",\"data\":{\"code\":\"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\\n  const R = 6371000;\\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\\n\\n  const dLat = toRad(b.lat - a.lat);\\n  const dLng = toRad(b.lng - a.lng);\\n\\n  const lat1 = toRad(a.lat);\\n  const lat2 = toRad(b.lat);\\n\\n  const sinDLat = Math.sin(dLat \u002F 2);\\n  const sinDLng = Math.sin(dLng \u002F 2);\\n\\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\\n\\n  return R * c;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-cache\",\"data\":{\"text\":\"Modelo de caché (CDN + servidor)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-cache\",\"data\":{\"text\":\"Cacheamos en dos capas. El CDN cachea el HTML\u002FJSON completo para tráfico anónimo. El caché del servidor almacena los resultados de los providers indexados por GeoQuery.cacheKey. La clave incluye tenant, scope y filtros acotados. No todo debe cachearse. La disponibilidad de reservas se excluirá más adelante.\"},\"type\":\"paragraph\"},{\"id\":\"list-cache\",\"data\":{\"items\":[\"La clave de caché del CDN varía según el encabezado Host: ese es el límite del tenant.\",\"La clave de caché del servidor incluye explícitamente tenantId. Nunca depender de un host implícito en memoria.\",\"TTL de caché: país\u002Fciudad 30–60 minutos en el CDN (stale-while-revalidate habilitado). Páginas near: 5 minutos.\",\"Mantenemos una palanca manual de invalidación por tenant (sufijo de versión en la clave de caché), usada durante migraciones y despliegues problemáticos.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-seo\",\"data\":{\"text\":\"Detalles de estabilidad SEO\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-seo\",\"data\":{\"items\":[\"Etiquetas canonical: siempre apuntan a la ruta jerárquica limpia (sin querystring).\",\"Robots: si existen parámetros de consulta no permitidos (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), establecer noindex para bloquear parámetros basura de enlaces externos.\",\"Paginación: rel=next\u002Fprev solo se genera para páginas > 1 y si resultCount > pageSize.\",\"Enlazado interno estable: la UI siempre emite rutas canónicas; la querystring solo se usa para filtros seleccionados por el usuario.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-extensions\",\"data\":{\"text\":\"Extensiones futuras sin sobrecarga\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-extensions\",\"data\":{\"text\":\"Extendemos añadiendo providers y presenters, no introduciendo funcionalidades en el resolver. Reservas, reseñas y mapas cuelgan de páginas de entidad o APIs dedicadas. Discovery sigue siendo una superficie de listado y filtrado. Ese límite se hace cumplir en las revisiones de código.\"},\"type\":\"paragraph\"},{\"id\":\"list-extensions\",\"data\":{\"items\":[\"Reservas: endpoints \u002Fapi\u002Fbooking separados. Discovery solo muestra badges de disponibilidad si están cacheados y no son personales.\",\"Reseñas: servicio\u002Fprovider de reseñas separado. Discovery lee campos de valoración agregados desde el GeoIndex (precalculados).\",\"Mapas: tiles y marcadores obtenidos desde \u002Fapi\u002Fdiscovery\u002Fmarkers con caché agresivo; no incrustarlos en la respuesta HTML si empeoran el TTFB.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-went-wrong\",\"data\":{\"text\":\"Un problema que salió mal (y lo que cambiamos)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-wrong\",\"data\":{\"text\":\"Lanzamos la primera versión con una clave de caché del servidor que NO incluía tenantId. En local «funcionaba». En staging parecía correcto. Luego en producción: el portal A empezó a mostrar listados del portal B en páginas de ciudad. Misma ruta, tenant distinto. Colisión de caché. Feo.\"},\"type\":\"paragraph\"},{\"id\":\"p-fix\",\"data\":{\"text\":\"La solución fue simple pero estricta: tenantId pasó a ser obligatorio en GeoQuery y la construcción de la clave de caché se movió al resolver para que no pudiera omitirse. También añadimos una aserción en tiempo de ejecución: si las entidades hidratadas contienen un tenantId diferente, lanzamos un error y omitimos la escritura en caché. Ruidoso a propósito.\"},\"type\":\"paragraph\"},{\"id\":\"h-query-plan\",\"data\":{\"text\":\"Query Plan y contrato de providers\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-provider\",\"data\":{\"code\":\"import type { GeoQuery } from '..\u002FGeoQuery';\\n\\nexport type GeoHit = {\\n  entityId: string;\\n  entityType: 'place' | 'service' | 'listing';\\n  lat: number;\\n  lng: number;\\n  citySlug?: string;\\n  countryCode: string;\\n  score?: number;\\n  distanceMeters?: number;\\n};\\n\\nexport type GeoResult = {\\n  hits: GeoHit[];\\n  total: number;\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport interface GeoProvider {\\n  search(query: GeoQuery): Promise\u003CGeoResult>;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"p-queryplan\",\"data\":{\"text\":\"El QueryPlan es un simple conmutador basado en la configuración del tenant y el tipo de scope. Por ejemplo: algunos tenants usan solo SQL; otros usan un servicio de portal remoto. Misma entrada GeoQuery. Misma salida GeoResult. Esto hace que los despliegues sean predecibles.\"},\"type\":\"paragraph\"},{\"id\":\"h-sql\",\"data\":{\"text\":\"GeoIndex SQL: esquema mínimo\",\"level\":3},\"type\":\"header\"},{\"id\":\"code-sql\",\"data\":{\"code\":\"CREATE TABLE geo_index (\\n  tenant_id     TEXT NOT NULL,\\n  entity_id     TEXT NOT NULL,\\n  entity_type   TEXT NOT NULL,\\n  country_code  TEXT NOT NULL,\\n  city_slug     TEXT,\\n  lat           DOUBLE PRECISION NOT NULL,\\n  lng           DOUBLE PRECISION NOT NULL,\\n  cell_prefix   TEXT NOT NULL,\\n  category      TEXT,\\n  rating_avg    DOUBLE PRECISION,\\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\\n  PRIMARY KEY (tenant_id, entity_id)\\n);\\n\\nCREATE INDEX geo_index_country_city\\n  ON geo_index (tenant_id, country_code, city_slug);\\n\\nCREATE INDEX geo_index_cell_prefix\\n  ON geo_index (tenant_id, country_code, cell_prefix);\\n\",\"language\":\"sql\"},\"type\":\"code\"},{\"id\":\"h-tradeoffs\",\"data\":{\"text\":\"Compromisos (explícitos)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-tradeoffs\",\"data\":{\"items\":[\"Intercambiamos precisión perfecta por URLs estables: las páginas near canonizan por cellId. Dos usuarios a 300 m pueden aterrizar en la misma página canónica. Aceptable.\",\"Intercambiamos frescura por cacheabilidad: las páginas de discovery no son en tiempo real. Las actualizaciones del índice pueden retrasarse.\",\"Evitamos un refactor de base de datos ahora, pero pagamos una ruta de escritura adicional para mantener el GeoIndex.\",\"Evitamos acoplar el enrutamiento del CMS, pero ahora poseemos un namespace de rutas separado (\u002Fdiscover). Es intencional y es una promesa que debemos mantener.\",\"Refinamos la distancia en el código de la aplicación para búsquedas near para evitar cálculos costosos en la base de datos, trasladando CPU a la capa de aplicación, que es más fácil de escalar horizontalmente.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-rollout\",\"data\":{\"text\":\"Plan de despliegue (práctico)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-rollout\",\"data\":{\"items\":[\"Desplegar el resolver y páginas vacías que devuelvan 404 detrás de un feature flag. Confirmar que el enrutamiento no colisiona con el catch-all del CMS.\",\"Construir un job de backfill del GeoIndex para un tenant. Validar recuentos frente a los datos origen. Esperar discrepancias y registrarlas.\",\"Habilitar solo las páginas de país. Vigilar el ratio de aciertos de caché y las estadísticas de rastreo.\",\"Habilitar después las páginas de ciudad. Las páginas near al final (son las generadoras de variantes).\",\"Tras ver claves de caché estables, añadir consumidores de la API JSON (marcadores de mapa, filtrado en cliente).\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-acceptance\",\"data\":{\"text\":\"Lista de aceptación\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-acceptance\",\"data\":{\"items\":[\"Aislamiento multi-tenant: la misma ruta en dos hosts nunca comparte entradas de caché del servidor.\",\"Las URL canónicas no incluyen parámetros de consulta y permanecen estables ante cambios de filtros.\",\"Los conflictos de slug del CMS no afectan a las rutas \u002Fdiscover.\",\"Las páginas near no generan permutaciones de URL no acotadas (r y pageSize limitados).\",\"El contrato del provider devuelve una paginación determinista y totales coherentes.\",\"La reconstrucción del GeoIndex puede ejecutarse sin downtime y sin bloquear las tablas origen durante largos periodos.\"],\"style\":\"unordered\"},\"type\":\"list\"}],\"version\":\"2.29.1\"}",{"time":212,"blocks":213,"version":509},1769827200000,[214,218,223,228,242,246,250,255,265,269,275,279,283,292,296,304,308,312,317,321,325,337,341,345,349,353,357,364,368,372,381,385,389,393,397,401,409,413,421,425,429,436,440,444,448,452,456,460,464,469,473,482,486,495,499],{"id":215,"data":216,"type":42},"h1",{"text":217,"level":40},"Geo Discovery: arquitectura canónica, diseño de URL, lógica del resolver, API y especificación de escalabilidad",{"id":219,"data":220,"type":222},"p-scope",{"text":221},"Este documento especifica la superficie de descubrimiento geográfico (país\u002Fciudad\u002Fradio) a través de múltiples portales (multi-tenant), sin forzar una refactorización inmediata de la base de datos y sin acoplarse al enrutamiento del CMS (página\u002Fpost). Mantiene la estabilidad SEO, es compatible con la caché y deja espacio para reservas, reseñas y mapas sin convertir la aplicación en un bloque monolítico.","paragraph",{"id":224,"data":225,"type":42},"h-goals",{"text":226,"level":227},"Restricciones y no-objetivos",2,{"id":229,"data":230,"type":241},"list-constraints",{"items":231,"style":240},[232,233,234,235,236,237,238,239],"Multi-tenant: la misma base de código sirve a múltiples portales. El tenant afecta el alcance del contenido, la identidad visual y, en ocasiones, la fuente de datos.","El descubrimiento geográfico debe soportar: país, ciudad y búsqueda por radio (alrededor de un punto).","Sin refactorización inmediata de la base de datos: no podemos reestructurar las tablas existentes en un esquema geo ideal en este momento.","Independencia del enrutamiento del CMS: las páginas geo no son “posts” ni “páginas”. No pueden ser bloqueadas por conflictos de slug del CMS.","Estabilidad SEO: las URL canónicas no deben cambiar cuando cambian los filtros u opciones de ordenación.","Compatibilidad con caché: el CDN y la caché del servidor deben tener claves predecibles. Evitar variaciones por usuario.","Separación estricta de responsabilidades: discovery, CMS y resolución del tenant son módulos separados con límites explícitos.","No-objetivo (por ahora): geocodificación perfecta. Aceptamos un solo geocoder, una estrategia de normalización y almacenamos el resultado normalizado.","unordered","list",{"id":243,"data":244,"type":42},"h-arch",{"text":245,"level":227},"Arquitectura canónica",{"id":247,"data":248,"type":222},"p-arch",{"text":249},"Implementamos el descubrimiento geográfico como un bounded context autónomo con una superficie pública mínima: (1) URL → Resolver, (2) Resolver → Query Plan, (3) Query Plan → Data Providers, (4) Respuesta → metadatos SEO y de caché. Las rutas del CMS nunca llaman a discovery. Discovery nunca llama al enrutamiento del CMS. Solo comparten utilidades de bajo nivel (HTTP, caché, contexto de tenant).",{"id":251,"data":252,"type":42},"h-components",{"text":253,"level":254},"Componentes clave",3,{"id":256,"data":257,"type":241},"list-components",{"items":258,"style":240},[259,260,261,262,263,264],"TenantContext: resuelve el tenant a partir del encabezado Host (o de un identificador de portal explícito en llamadas internas).","GeoResolver: analiza y valida los segmentos de URL geográficos; emite un GeoQuery normalizado.","GeoIndex (Read Model): tabla o colección separada que mapea entityId → lat\u002Flng + scope del tenant + campos mínimos buscables. Esto evita refactorizar las tablas de la base de datos origen.","Data Providers: fuentes intercambiables (por ejemplo, tablas SQL existentes, API de WordPress, otro servicio de portal), encapsuladas detrás de una interfaz.","SEO Router: genera la URL canónica y los metadatos (canonical, hreflang si es necesario, flags robots).","Capa de caché: claves de caché del CDN y caché del servidor con claves tenant-scoped y versionado.",{"id":266,"data":267,"type":42},"h-folder",{"text":268,"level":227},"Estructura de carpetas",{"id":270,"data":271,"type":274},"code-folder",{"code":272,"language":273},"apps\u002F\n  web\u002F\n    routes\u002F\n      discovery\u002F                  # discovery entry points (not CMS)\n    pages\u002F\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\n  server\u002F\n    src\u002F\n      tenant\u002F\n        TenantContext.ts\n        tenantConfig.ts\n      discovery\u002F\n        geo\u002F\n          GeoResolver.ts\n          GeoQuery.ts\n          GeoCanonical.ts\n          GeoController.ts\n          providers\u002F\n            GeoProvider.ts\n            SqlGeoProvider.ts\n            RemoteGeoProvider.ts\n          index\u002F\n            GeoIndexRepository.ts\n            migrations\u002F\n      cache\u002F\n        Cache.ts\n        cacheKeys.ts\n      http\u002F\n        errors.ts\n        requestContext.ts\npackages\u002F\n  shared\u002F\n    src\u002F\n      geo\u002F\n        normalize.ts\n        haversine.ts\n      validation\u002F\n        zod.ts\n","text","code",{"id":276,"data":277,"type":42},"h-url",{"text":278,"level":227},"Diseño de URL (SEO Canonical)",{"id":280,"data":281,"type":222},"p-url",{"text":282},"Mantenemos la URL canónica puramente jerárquica y legible para humanos. Los filtros y la ordenación permanecen en la querystring, pero NO afectan al canonical. La búsqueda por radio utiliza una página “near” estable con un canonical basado en una celda de coordenadas redondeada, no en lat\u002Flng en bruto. Esto evita variantes infinitas de URL y previene la explosión de la caché. Es un compromiso deliberado.",{"id":284,"data":285,"type":241},"list-url",{"items":286,"style":240},[287,288,289,290,291],"Landing de país: \u002Fdiscover\u002F{countryCode} (ejemplo: \u002Fdiscover\u002Fde)","Landing de ciudad: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (ejemplo: \u002Fdiscover\u002Fde\u002Fmunich)","Near (radio): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (ejemplo: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z), donde cellId es un identificador corto tipo geohash","Parámetros de consulta opcionales (no canónicos): ?q=shih-tzu&category=pet-care&sort=rating&page=2","El tenant NO está en la ruta. El tenant es el host (portal-a.tld, portal-b.tld). Las llamadas internas envían x-tenant-id.",{"id":293,"data":294,"type":42},"h-canonical-rules",{"text":295,"level":254},"Reglas de canonicalización",{"id":297,"data":298,"type":241},"list-canonical",{"items":299,"style":240},[300,301,302,303],"Las páginas de país\u002Fciudad canonizan hacia su propia ruta limpia (sin querystring).","Las páginas near canonizan a \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. El canonical se deriva de una celda redondeada, no de las coordenadas entrantes.","page=1 se omite del canonical y de la generación de enlaces internos.","Las combinaciones no soportadas (por ejemplo, desajuste de país) devuelven 404, no una redirección. Las cadenas de redirección perjudicaban el presupuesto de rastreo en las pruebas.",{"id":305,"data":306,"type":42},"h-resolver",{"text":307,"level":227},"Lógica del resolver",{"id":309,"data":310,"type":222},"p-resolver",{"text":311},"La entrada del resolver es (tenant, pathname, query). La salida es un GeoQuery normalizado con un query plan. No hay acceso a base de datos dentro del resolver. Esta separación fue crucial más adelante: nos salvó de un bug de caché bastante desagradable.",{"id":313,"data":314,"type":274},"code-types",{"code":315,"language":316},"export type TenantId = string;\n\nexport type GeoScope =\n  | { kind: 'country'; countryCode: string }\n  | { kind: 'city'; countryCode: string; citySlug: string }\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\n\nexport type GeoFilters = {\n  q?: string;\n  category?: string;\n  sort?: 'relevance' | 'rating' | 'distance';\n  page: number;\n  pageSize: number;\n};\n\nexport type GeoQuery = {\n  tenantId: TenantId;\n  scope: GeoScope;\n  filters: GeoFilters;\n  canonicalPath: string;\n  cacheKey: string;\n};\n","ts",{"id":318,"data":319,"type":274},"code-resolver",{"code":320,"language":316},"import { z } from 'zod';\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\n\nconst QuerySchema = z.object({\n  q: z.string().trim().min(1).max(120).optional(),\n  category: z.string().trim().min(1).max(60).optional(),\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\n  page: z.coerce.number().int().min(1).max(200).default(1),\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\n});\n\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\n\nexport function resolveGeo(\n  tenantId: TenantId,\n  pathname: string,\n  query: Record\u003Cstring, unknown>\n): GeoQuery {\n  const filters = QuerySchema.parse(query);\n  const parts = pathname.split('\u002F').filter(Boolean);\n\n  if (parts[0] !== 'discover') {\n    throw new Error('Not a discovery route');\n  }\n\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\n\n  let scope: GeoQuery['scope'];\n  if (parts.length === 2) {\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\n  } else if (parts[2] === 'near') {\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\n  } else {\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\n  }\n\n  const canonicalPath = canonicalizePath(scope);\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\n\n  return {\n    tenantId,\n    scope,\n    filters: { ...filters, sort: filters.sort ?? 'relevance' },\n    canonicalPath,\n    cacheKey\n  };\n}\n\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\n  switch (scope.kind) {\n    case 'country': return `\u002Fdiscover\u002F${scope.countryCode}`;\n    case 'city': return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\n    case 'near': return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\n  }\n}\n\nfunction buildCacheKey(\n  tenantId: string,\n  scope: GeoQuery['scope'],\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\n): string {\n  const q = filters.q ? `q=${filters.q}` : '';\n  const c = filters.category ? `cat=${filters.category}` : '';\n  const s = `sort=${filters.sort ?? 'relevance'}`;\n  const p = `p=${filters.page}`;\n  const ps = `ps=${filters.pageSize}`;\n\n  const scopeKey =\n    scope.kind === 'country'\n      ? `country:${scope.countryCode}`\n      : scope.kind === 'city'\n        ? `city:${scope.countryCode}:${scope.citySlug}`\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\n\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\n}\n",{"id":322,"data":323,"type":42},"h-flow",{"text":324,"level":227},"Flujo de solicitud paso a paso",{"id":326,"data":327,"type":241},"list-flow",{"items":328,"style":336},[329,330,331,332,333,334,335],"El Edge\u002FCDN recibe la solicitud. La clave de caché incluye el host + la ruta + parámetros de consulta acotados (q, category, sort, page, pageSize, r).","El servidor de la aplicación crea el TenantContext a partir del encabezado Host. Sin acceso a base de datos todavía.","El GeoResolver analiza la URL y produce un GeoQuery con canonicalPath y cacheKey del servidor.","El GeoController construye un QueryPlan y decide qué provider(s) usar según la configuración del tenant y el tipo de scope.","El provider ejecuta una consulta del read-model contra el GeoIndex (rápida) y luego hidrata los resultados desde la fuente existente (DB SQL, API CMS) usando los entity IDs.","El ensamblador de la respuesta adjunta metadatos SEO: canonical, robots y enlaces de paginación.","El servidor establece encabezados de caché (s-maxage + stale-while-revalidate). El cuerpo está acotado por tenant y nunca se comparte entre tenants.","ordered",{"id":338,"data":339,"type":42},"h-read-model",{"text":340,"level":227},"Estrategia sin refactorización: Read Model GeoIndex",{"id":342,"data":343,"type":222},"p-read-model",{"text":344},"No podemos reestructurar las tablas de contenido existentes de inmediato. Por ello añadimos un GeoIndex separado que puede reconstruirse de forma independiente. Almacena solo lo necesario para una discovery eficiente: tenantId, entityId, entityType, countryCode, citySlug, lat, lng y algunos campos de filtrado. La hidratación obtiene el objeto completo desde la fuente actual.",{"id":346,"data":347,"type":222},"p-tradeoffs-read-model",{"text":348},"Compromiso: consistencia eventual. El retraso en la reconstrucción del índice es aceptable para las páginas de discovery. SLA: las actualizaciones aparecen en un plazo de 15 minutos. Si más adelante necesitamos tiempo real (disponibilidad de reservas), esa será otra superficie y no debe reutilizar la caché de discovery.",{"id":350,"data":351,"type":42},"h-api",{"text":352,"level":227},"Superficie de API",{"id":354,"data":355,"type":222},"p-api",{"text":356},"Dos superficies: (A) páginas HTML para SEO y usuarios, (B) API JSON para renderizado del cliente y extensiones futuras. Mismo resolver y mismo query plan. Presentadores distintos.",{"id":358,"data":359,"type":241},"list-api",{"items":360,"style":240},[361,362,363],"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}","API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..","Admin (interno): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protegido), POST \u002Finternal\u002Fgeoindex\u002Fupsert (opcional, más adelante)",{"id":365,"data":366,"type":274},"code-api-handler",{"code":367,"language":316},"import type { IncomingMessage, ServerResponse } from 'http';\nimport { resolveGeo } from '.\u002FGeoResolver';\nimport { runQueryPlan } from '.\u002FGeoController';\n\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\n\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\n\n  const scope = url.searchParams.get('scope') ?? 'country';\n  const country = url.searchParams.get('country') ?? '';\n  const city = url.searchParams.get('city');\n  const cell = url.searchParams.get('cell');\n\n  const pseudoPath =\n    scope === 'city' && city\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\n      : scope === 'near' && cell\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\n        : `\u002Fdiscover\u002F${country}`;\n\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\n  const result = await runQueryPlan(geoQuery);\n\n  res.statusCode = 200;\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\n\n  res.end(JSON.stringify(result));\n}\n",{"id":369,"data":370,"type":42},"h-scalability",{"text":371,"level":227},"Escalabilidad y rendimiento",{"id":373,"data":374,"type":241},"list-scale",{"items":375,"style":240},[376,377,378,379,380],"Objetivo principal de rendimiento: servir el HTML de discovery desde el CDN para los landings geo populares (país\u002Fciudad).","Las páginas near son cacheables pero tienen más variantes (cellId + r + q + category + sort + page). Acotamos r y pageSize y validamos estrictamente los filtros.","La consulta al GeoIndex debe ser rápida: usar índices por tenantId + countryCode + citySlug; para near usar buckets de celdas (coincidencia por prefijo) y refinar la distancia en la aplicación.","Evitar cálculos haversine SQL costosos sobre grandes conjuntos de datos. Parece simple. No lo es.","La hidratación se realiza por lotes según la lista de entityId: una consulta por entityType, no N+1.",{"id":382,"data":383,"type":42},"h-near-strategy",{"text":384,"level":254},"Estrategia de búsqueda por radio (páginas near)",{"id":386,"data":387,"type":222},"p-near",{"text":388},"No ejecutamos un escaneo de radio completo sobre todas las filas. En su lugar: (1) el cellId mapea a un bucket delimitador (prefijo tipo geohash), (2) se obtienen candidatos del GeoIndex por prefijo de bucket, (3) se refina en la aplicación usando distancia haversine, (4) se ordena y pagina. Esto mantiene la carga de la base de datos predecible.",{"id":390,"data":391,"type":274},"code-haversine",{"code":392,"language":316},"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\n  const R = 6371000;\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\n\n  const dLat = toRad(b.lat - a.lat);\n  const dLng = toRad(b.lng - a.lng);\n\n  const lat1 = toRad(a.lat);\n  const lat2 = toRad(b.lat);\n\n  const sinDLat = Math.sin(dLat \u002F 2);\n  const sinDLng = Math.sin(dLng \u002F 2);\n\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\n\n  return R * c;\n}\n",{"id":394,"data":395,"type":42},"h-cache",{"text":396,"level":227},"Modelo de caché (CDN + servidor)",{"id":398,"data":399,"type":222},"p-cache",{"text":400},"Cacheamos en dos capas. El CDN cachea el HTML\u002FJSON completo para tráfico anónimo. El caché del servidor almacena los resultados de los providers indexados por GeoQuery.cacheKey. La clave incluye tenant, scope y filtros acotados. No todo debe cachearse. La disponibilidad de reservas se excluirá más adelante.",{"id":402,"data":403,"type":241},"list-cache",{"items":404,"style":240},[405,406,407,408],"La clave de caché del CDN varía según el encabezado Host: ese es el límite del tenant.","La clave de caché del servidor incluye explícitamente tenantId. Nunca depender de un host implícito en memoria.","TTL de caché: país\u002Fciudad 30–60 minutos en el CDN (stale-while-revalidate habilitado). Páginas near: 5 minutos.","Mantenemos una palanca manual de invalidación por tenant (sufijo de versión en la clave de caché), usada durante migraciones y despliegues problemáticos.",{"id":410,"data":411,"type":42},"h-seo",{"text":412,"level":227},"Detalles de estabilidad SEO",{"id":414,"data":415,"type":241},"list-seo",{"items":416,"style":240},[417,418,419,420],"Etiquetas canonical: siempre apuntan a la ruta jerárquica limpia (sin querystring).","Robots: si existen parámetros de consulta no permitidos (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), establecer noindex para bloquear parámetros basura de enlaces externos.","Paginación: rel=next\u002Fprev solo se genera para páginas > 1 y si resultCount > pageSize.","Enlazado interno estable: la UI siempre emite rutas canónicas; la querystring solo se usa para filtros seleccionados por el usuario.",{"id":422,"data":423,"type":42},"h-extensions",{"text":424,"level":227},"Extensiones futuras sin sobrecarga",{"id":426,"data":427,"type":222},"p-extensions",{"text":428},"Extendemos añadiendo providers y presenters, no introduciendo funcionalidades en el resolver. Reservas, reseñas y mapas cuelgan de páginas de entidad o APIs dedicadas. Discovery sigue siendo una superficie de listado y filtrado. Ese límite se hace cumplir en las revisiones de código.",{"id":430,"data":431,"type":241},"list-extensions",{"items":432,"style":240},[433,434,435],"Reservas: endpoints \u002Fapi\u002Fbooking separados. Discovery solo muestra badges de disponibilidad si están cacheados y no son personales.","Reseñas: servicio\u002Fprovider de reseñas separado. Discovery lee campos de valoración agregados desde el GeoIndex (precalculados).","Mapas: tiles y marcadores obtenidos desde \u002Fapi\u002Fdiscovery\u002Fmarkers con caché agresivo; no incrustarlos en la respuesta HTML si empeoran el TTFB.",{"id":437,"data":438,"type":42},"h-went-wrong",{"text":439,"level":227},"Un problema que salió mal (y lo que cambiamos)",{"id":441,"data":442,"type":222},"p-wrong",{"text":443},"Lanzamos la primera versión con una clave de caché del servidor que NO incluía tenantId. En local «funcionaba». En staging parecía correcto. Luego en producción: el portal A empezó a mostrar listados del portal B en páginas de ciudad. Misma ruta, tenant distinto. Colisión de caché. Feo.",{"id":445,"data":446,"type":222},"p-fix",{"text":447},"La solución fue simple pero estricta: tenantId pasó a ser obligatorio en GeoQuery y la construcción de la clave de caché se movió al resolver para que no pudiera omitirse. También añadimos una aserción en tiempo de ejecución: si las entidades hidratadas contienen un tenantId diferente, lanzamos un error y omitimos la escritura en caché. Ruidoso a propósito.",{"id":449,"data":450,"type":42},"h-query-plan",{"text":451,"level":227},"Query Plan y contrato de providers",{"id":453,"data":454,"type":274},"code-provider",{"code":455,"language":316},"import type { GeoQuery } from '..\u002FGeoQuery';\n\nexport type GeoHit = {\n  entityId: string;\n  entityType: 'place' | 'service' | 'listing';\n  lat: number;\n  lng: number;\n  citySlug?: string;\n  countryCode: string;\n  score?: number;\n  distanceMeters?: number;\n};\n\nexport type GeoResult = {\n  hits: GeoHit[];\n  total: number;\n  page: number;\n  pageSize: number;\n};\n\nexport interface GeoProvider {\n  search(query: GeoQuery): Promise\u003CGeoResult>;\n}\n",{"id":457,"data":458,"type":222},"p-queryplan",{"text":459},"El QueryPlan es un simple conmutador basado en la configuración del tenant y el tipo de scope. Por ejemplo: algunos tenants usan solo SQL; otros usan un servicio de portal remoto. Misma entrada GeoQuery. Misma salida GeoResult. Esto hace que los despliegues sean predecibles.",{"id":461,"data":462,"type":42},"h-sql",{"text":463,"level":254},"GeoIndex SQL: esquema mínimo",{"id":465,"data":466,"type":274},"code-sql",{"code":467,"language":468},"CREATE TABLE geo_index (\n  tenant_id     TEXT NOT NULL,\n  entity_id     TEXT NOT NULL,\n  entity_type   TEXT NOT NULL,\n  country_code  TEXT NOT NULL,\n  city_slug     TEXT,\n  lat           DOUBLE PRECISION NOT NULL,\n  lng           DOUBLE PRECISION NOT NULL,\n  cell_prefix   TEXT NOT NULL,\n  category      TEXT,\n  rating_avg    DOUBLE PRECISION,\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\n  PRIMARY KEY (tenant_id, entity_id)\n);\n\nCREATE INDEX geo_index_country_city\n  ON geo_index (tenant_id, country_code, city_slug);\n\nCREATE INDEX geo_index_cell_prefix\n  ON geo_index (tenant_id, country_code, cell_prefix);\n","sql",{"id":470,"data":471,"type":42},"h-tradeoffs",{"text":472,"level":227},"Compromisos (explícitos)",{"id":474,"data":475,"type":241},"list-tradeoffs",{"items":476,"style":240},[477,478,479,480,481],"Intercambiamos precisión perfecta por URLs estables: las páginas near canonizan por cellId. Dos usuarios a 300 m pueden aterrizar en la misma página canónica. Aceptable.","Intercambiamos frescura por cacheabilidad: las páginas de discovery no son en tiempo real. Las actualizaciones del índice pueden retrasarse.","Evitamos un refactor de base de datos ahora, pero pagamos una ruta de escritura adicional para mantener el GeoIndex.","Evitamos acoplar el enrutamiento del CMS, pero ahora poseemos un namespace de rutas separado (\u002Fdiscover). Es intencional y es una promesa que debemos mantener.","Refinamos la distancia en el código de la aplicación para búsquedas near para evitar cálculos costosos en la base de datos, trasladando CPU a la capa de aplicación, que es más fácil de escalar horizontalmente.",{"id":483,"data":484,"type":42},"h-rollout",{"text":485,"level":227},"Plan de despliegue (práctico)",{"id":487,"data":488,"type":241},"list-rollout",{"items":489,"style":336},[490,491,492,493,494],"Desplegar el resolver y páginas vacías que devuelvan 404 detrás de un feature flag. Confirmar que el enrutamiento no colisiona con el catch-all del CMS.","Construir un job de backfill del GeoIndex para un tenant. Validar recuentos frente a los datos origen. Esperar discrepancias y registrarlas.","Habilitar solo las páginas de país. Vigilar el ratio de aciertos de caché y las estadísticas de rastreo.","Habilitar después las páginas de ciudad. Las páginas near al final (son las generadoras de variantes).","Tras ver claves de caché estables, añadir consumidores de la API JSON (marcadores de mapa, filtrado en cliente).",{"id":496,"data":497,"type":42},"h-acceptance",{"text":498,"level":227},"Lista de aceptación",{"id":500,"data":501,"type":241},"list-acceptance",{"items":502,"style":240},[503,504,505,506,507,508],"Aislamiento multi-tenant: la misma ruta en dos hosts nunca comparte entradas de caché del servidor.","Las URL canónicas no incluyen parámetros de consulta y permanecen estables ante cambios de filtros.","Los conflictos de slug del CMS no afectan a las rutas \u002Fdiscover.","Las páginas near no generan permutaciones de URL no acotadas (r y pageSize limitados).","El contrato del provider devuelve una paginación determinista y totales coherentes.","La reconstrucción del GeoIndex puede ejecutarse sin downtime y sin bloquear las tablas origen durante largos periodos.","2.29.1","Arquitectura de descubrimiento geobasada para portales multi-inquilino. Define URL canónicas, lógica de resolución, estrategia de caché y un modelo de lectura geográfico sin acoplamiento con CMS ni refactorización de base de datos. Diseñada para la estabilidad SEO, la escalabilidad y futuras extensiones como reservas y mapas.","\u002Fuploads\u002F2026\u002F01\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification-1769890763607-7rghbp.webp","canonical-architecture-url-design-resolver-logic-api-scalability-specification-1769890763607-7rghbp","PUBLISHED","2026-01-31T06:12:00.000Z","2026-01-31T20:12:05.337Z","2026-02-20T20:40:40.678Z",{"en":518,"de":519,"sr":520,"es":521,"fr":522,"it":523,"ru":524,"zh":525},"\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fde\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fsr\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fes\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Ffr\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fit\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fru\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fzh\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification",[527,531,535,539,543],{"id":528,"name":529,"slug":530},108,"Evaluación de entrega","delivery-assessment",{"id":532,"name":533,"slug":534},107,"Evaluaciones","assessments",{"id":536,"name":537,"slug":538},45,"Modelo de referencia: Plataforma digital","digital-platform",{"id":540,"name":541,"slug":542},66,"Operaciones de contenido","content-ops",{"id":544,"name":545,"slug":546},79,"Playbook: Endurecimiento de seguridad","security-hardening",{"id":548,"login":549,"email":550,"displayName":551},"20","rooth8233","","Aleksandar Stajić",[553,783],{"lang":554,"title":555,"content":556,"contentJson":557,"excerpt":782},"en","Canonical Architecture, URL Design, Resolver Logic, API & Scalability Specification","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1\",\"data\":{\"text\":\"Geo Discovery: Canonical Architecture, URL Design, Resolver Logic, API & Scalability Spec\",\"level\":1},\"type\":\"header\"},{\"id\":\"p-scope\",\"data\":{\"text\":\"This document specifies the geo-based discovery surface (country\u002Fcity\u002Fradius) across multiple portals (multi-tenant), without forcing immediate DB refactoring, and without coupling to CMS routing (page\u002Fpost). It keeps SEO stable, stays cache-friendly, and leaves room for booking\u002Freviews\u002Fmaps without turning the app into a blob.\"},\"type\":\"paragraph\"},{\"id\":\"h-goals\",\"data\":{\"text\":\"Constraints and Non-Goals\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-constraints\",\"data\":{\"items\":[\"Multi-tenant: same codebase serves multiple portals. Tenant affects content scope, branding, and sometimes data source.\",\"Geo discovery must support: country, city, radius search (around a point).\",\"No immediate database refactoring: we cannot reshape existing tables into a perfect geo schema right now.\",\"Independence from CMS routing: geo pages are not \\\"posts\\\" or \\\"pages\\\". They cannot be blocked by CMS slug conflicts.\",\"SEO stability: canonical URLs must not change when filters\u002Fsort options change.\",\"Cache friendliness: CDN + server cache should have predictable keys. Avoid per-user variation.\",\"Strict separation of concerns: discovery, CMS, and tenant resolution are separate modules with explicit boundaries.\",\"Non-goal (for now): perfect geocoding. We accept one geocoder, one normalization strategy, and we store the normalized result.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-arch\",\"data\":{\"text\":\"Canonical Architecture\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-arch\",\"data\":{\"text\":\"We implement geo discovery as its own bounded context with a small public surface: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> SEO + Cache metadata. CMS routes never call into discovery. Discovery never calls CMS routing. They share only low-level utilities (HTTP, caching, tenant context).\"},\"type\":\"paragraph\"},{\"id\":\"h-components\",\"data\":{\"text\":\"Key Components\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-components\",\"data\":{\"items\":[\"TenantContext: resolves tenant from host header (or explicit portal id in internal calls).\",\"GeoResolver: parses + validates geo URL segments; emits a normalized GeoQuery.\",\"GeoIndex (Read Model): a separate table\u002Fcollection that maps entityId -> lat\u002Flng + tenant scope + minimal searchable fields. This avoids refactoring source DB tables.\",\"Data Providers: pluggable sources (e.g., existing SQL tables, WordPress API, another portal service). They are behind an interface.\",\"SEO Router: produces canonical URL and meta (canonical, hreflang if needed, robots flags).\",\"Caching Layer: CDN cache keys + server-side cache with tenant-scoped keys and versioning.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-folder\",\"data\":{\"text\":\"Folder Structure\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-folder\",\"data\":{\"code\":\"apps\u002F\\n  web\u002F\\n    routes\u002F\\n      discovery\u002F                  # discovery entry points (not CMS)\\n    pages\u002F\\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\\n  server\u002F\\n    src\u002F\\n      tenant\u002F\\n        TenantContext.ts\\n        tenantConfig.ts\\n      discovery\u002F\\n        geo\u002F\\n          GeoResolver.ts\\n          GeoQuery.ts\\n          GeoCanonical.ts\\n          GeoController.ts\\n          providers\u002F\\n            GeoProvider.ts\\n            SqlGeoProvider.ts\\n            RemoteGeoProvider.ts\\n          index\u002F\\n            GeoIndexRepository.ts\\n            migrations\u002F\\n      cache\u002F\\n        Cache.ts\\n        cacheKeys.ts\\n      http\u002F\\n        errors.ts\\n        requestContext.ts\\npackages\u002F\\n  shared\u002F\\n    src\u002F\\n      geo\u002F\\n        normalize.ts\\n        haversine.ts\\n      validation\u002F\\n        zod.ts\\n\",\"language\":\"text\"},\"type\":\"code\"},{\"id\":\"h-url\",\"data\":{\"text\":\"URL Design (SEO Canonical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-url\",\"data\":{\"text\":\"We keep the canonical URL purely hierarchical and human-readable. Filters\u002Fsort stay in querystring but do NOT affect the canonical. Radius search uses a stable \\\"near\\\" page with a canonical based on a rounded coordinate cell, not the raw lat\u002Flng. That prevents infinite URL variants. It also prevents cache explosion. It’s a deliberate compromise.\"},\"type\":\"paragraph\"},{\"id\":\"list-url\",\"data\":{\"items\":[\"Country landing: \u002Fdiscover\u002F{countryCode} (example: \u002Fdiscover\u002Fde)\",\"City landing: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (example: \u002Fdiscover\u002Fde\u002Fmunich)\",\"Near (radius): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (example: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) where cellId is a short geohash-ish identifier\",\"Optional query params (non-canonical): ?q=shih-tzu&category=pet-care&sort=rating&page=2\",\"Tenant is NOT in the path. Tenant is the host (portal-a.tld, portal-b.tld). Internal calls pass x-tenant-id.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-canonical-rules\",\"data\":{\"text\":\"Canonical Rules\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-canonical\",\"data\":{\"items\":[\"Country\u002Fcity pages canonicalize to their own clean path (no querystring).\",\"Near pages canonicalize to \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. The canonical is derived from a rounded cell, not the incoming coordinates.\",\"page=1 is omitted from canonical and from internal link generation.\",\"Unsupported combinations (e.g., country mismatch) return 404, not a redirect. Redirect chains were hurting crawl budget in testing.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-resolver\",\"data\":{\"text\":\"Resolver Logic\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-resolver\",\"data\":{\"text\":\"Resolver input is (tenant, pathname, query). Resolver output is a normalized GeoQuery with a query plan. No DB access inside the resolver. That separation mattered later. It saved us from a nasty caching bug.\"},\"type\":\"paragraph\"},{\"id\":\"code-types\",\"data\":{\"code\":\"export type TenantId = string;\\n\\nexport type GeoScope =\\n  | { kind: 'country'; countryCode: string }\\n  | { kind: 'city'; countryCode: string; citySlug: string }\\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\\n\\nexport type GeoFilters = {\\n  q?: string;\\n  category?: string;\\n  sort?: 'relevance' | 'rating' | 'distance';\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport type GeoQuery = {\\n  tenantId: TenantId;\\n  scope: GeoScope;\\n  filters: GeoFilters;\\n  canonicalPath: string;\\n  cacheKey: string;\\n};\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"code-resolver\",\"data\":{\"code\":\"import { z } from 'zod';\\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\\n\\nconst QuerySchema = z.object({\\n  q: z.string().trim().min(1).max(120).optional(),\\n  category: z.string().trim().min(1).max(60).optional(),\\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\\n  page: z.coerce.number().int().min(1).max(200).default(1),\\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\\n});\\n\\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\\n\\nexport function resolveGeo(\\n  tenantId: TenantId,\\n  pathname: string,\\n  query: Record\u003Cstring, unknown>\\n): GeoQuery {\\n  const filters = QuerySchema.parse(query);\\n  const parts = pathname.split('\u002F').filter(Boolean);\\n\\n  \u002F\u002F \u002Fdiscover\u002F{country}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\\n  if (parts[0] !== 'discover') {\\n    throw new Error('Not a discovery route');\\n  }\\n\\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\\n\\n  let scope: GeoQuery['scope'];\\n  if (parts.length === 2) {\\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\\n  } else if (parts[2] === 'near') {\\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\\n    \u002F\u002F radius is NOT in the path, but is bounded.\\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\\n  } else {\\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\\n  }\\n\\n  const canonicalPath = canonicalizePath(scope);\\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\\n\\n  return {\\n    tenantId,\\n    scope,\\n    filters: {\\n      ...filters,\\n      sort: filters.sort ?? 'relevance'\\n    },\\n    canonicalPath,\\n    cacheKey\\n  };\\n}\\n\\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\\n  switch (scope.kind) {\\n    case 'country':\\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\\n    case 'city':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\\n    case 'near':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\\n  }\\n}\\n\\nfunction buildCacheKey(\\n  tenantId: string,\\n  scope: GeoQuery['scope'],\\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\\n): string {\\n  \u002F\u002F Note: canonical ignores querystring, cache does not.\\n  \u002F\u002F But we keep it bounded and explicit.\\n  const q = filters.q ? `q=${filters.q}` : '';\\n  const c = filters.category ? `cat=${filters.category}` : '';\\n  const s = `sort=${filters.sort ?? 'relevance'}`;\\n  const p = `p=${filters.page}`;\\n  const ps = `ps=${filters.pageSize}`;\\n\\n  const scopeKey =\\n    scope.kind === 'country'\\n      ? `country:${scope.countryCode}`\\n      : scope.kind === 'city'\\n        ? `city:${scope.countryCode}:${scope.citySlug}`\\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\\n\\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-flow\",\"data\":{\"text\":\"Step-by-Step Request Flow\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-flow\",\"data\":{\"items\":[\"Edge\u002FCDN receives request. Cache key includes host + path + bounded query params (q, category, sort, page, pageSize, r).\",\"App server creates TenantContext from Host header. No DB yet.\",\"GeoResolver parses URL. Produces GeoQuery with canonicalPath and server cacheKey.\",\"GeoController builds a QueryPlan. It decides which provider(s) to hit based on tenant config and scope kind.\",\"Provider executes read-model query against GeoIndex (fast). Then hydrates results from the existing source DB\u002FAPI using entity IDs (no refactor required).\",\"Response assembler attaches SEO metadata: canonical, robots, pagination links.\",\"Server sets cache headers (s-maxage + stale-while-revalidate). Body is tenant-scoped, never shared across tenants.\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-read-model\",\"data\":{\"text\":\"No-Refactor Strategy: GeoIndex Read Model\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-read-model\",\"data\":{\"text\":\"We cannot restructure existing content tables right now. So we add a separate GeoIndex that we can rebuild independently. It stores just enough to do discovery efficiently: tenantId, entityId, entityType, countryCode, citySlug, lat, lng, and a few filter fields. Hydration fetches the full object from the current source (SQL rows, CMS API, etc.).\"},\"type\":\"paragraph\"},{\"id\":\"p-tradeoffs-read-model\",\"data\":{\"text\":\"Trade-off: eventual consistency. Index rebuild lag is acceptable for discovery pages. We set SLA: updates appear within 15 minutes. If we need real-time later (booking availability), that’s a different surface and should not reuse the discovery cache.\"},\"type\":\"paragraph\"},{\"id\":\"h-api\",\"data\":{\"text\":\"API Surface\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-api\",\"data\":{\"text\":\"Two surfaces: (A) HTML pages for SEO and users, (B) JSON API for client rendering and future extensions. Same resolver + same query plan. Different presenters.\"},\"type\":\"paragraph\"},{\"id\":\"list-api\",\"data\":{\"items\":[\"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\",\"API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..\",\"Admin (internal): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protected), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, later)\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"code-api-handler\",\"data\":{\"code\":\"import type { IncomingMessage, ServerResponse } from 'http';\\nimport { resolveGeo } from '.\u002FGeoResolver';\\nimport { runQueryPlan } from '.\u002FGeoController';\\n\\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\\n\\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\\n\\n  \u002F\u002F We reuse the same resolver by mapping query -> a pseudo-path.\\n  \u002F\u002F This keeps logic aligned between HTML and API.\\n  const scope = url.searchParams.get('scope') ?? 'country';\\n  const country = url.searchParams.get('country') ?? '';\\n  const city = url.searchParams.get('city');\\n  const cell = url.searchParams.get('cell');\\n\\n  const pseudoPath =\\n    scope === 'city' && city\\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\\n      : scope === 'near' && cell\\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\\n        : `\u002Fdiscover\u002F${country}`;\\n\\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\\n  const result = await runQueryPlan(geoQuery);\\n\\n  res.statusCode = 200;\\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\\n  \u002F\u002F cache: public at CDN, tenant-specific key already handled upstream\\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\\n\\n  res.end(JSON.stringify(result));\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-scalability\",\"data\":{\"text\":\"Scalability & Performance\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-scale\",\"data\":{\"items\":[\"Primary performance goal: serve cached discovery HTML from CDN for popular geo landings (country\u002Fcity).\",\"Near pages are cacheable but have more variants (cellId + r + q + category + sort + page). We bound r, pageSize, and validate filters strictly.\",\"GeoIndex query must be fast: use tenantId + countryCode + citySlug indexes; for near use cell buckets (prefix match) then refine by distance in app.\",\"Avoid expensive SQL haversine over large datasets. It looks simple. It is not.\",\"Hydration is batched by entityId list. One query per entityType, not N+1.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-near-strategy\",\"data\":{\"text\":\"Radius Search Strategy (Near Pages)\",\"level\":3},\"type\":\"header\"},{\"id\":\"p-near\",\"data\":{\"text\":\"We do not run a full radius scan over all rows. Instead: (1) cellId maps to a bounding bucket (geohash-ish prefix), (2) fetch candidates from GeoIndex by bucket prefix, (3) refine in application with haversine distance, (4) sort + paginate. This keeps DB load predictable.\"},\"type\":\"paragraph\"},{\"id\":\"code-haversine\",\"data\":{\"code\":\"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\\n  const R = 6371000;\\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\\n\\n  const dLat = toRad(b.lat - a.lat);\\n  const dLng = toRad(b.lng - a.lng);\\n\\n  const lat1 = toRad(a.lat);\\n  const lat2 = toRad(b.lat);\\n\\n  const sinDLat = Math.sin(dLat \u002F 2);\\n  const sinDLng = Math.sin(dLng \u002F 2);\\n\\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\\n\\n  return R * c;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-cache\",\"data\":{\"text\":\"Caching Model (CDN + Server)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-cache\",\"data\":{\"text\":\"We cache at two layers. CDN caches the full HTML\u002FJSON for anonymous traffic. Server cache stores provider results keyed by GeoQuery.cacheKey. The key includes tenant, scope, and bounded filters. Not everything should be cached. Booking availability later will be excluded.\"},\"type\":\"paragraph\"},{\"id\":\"list-cache\",\"data\":{\"items\":[\"CDN cache key varies by Host header. That is the tenant boundary.\",\"Server cache key includes tenantId explicitly. Never rely on implicit host in-process.\",\"Cache TTLs: country\u002Fcity 30–60 minutes at CDN (stale-while-revalidate enabled). near pages 5 minutes.\",\"We keep a manual bust lever per tenant (version suffix in cache key). Used during migrations and bad deploys.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-seo\",\"data\":{\"text\":\"SEO Stability Details\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-seo\",\"data\":{\"items\":[\"Canonical tags: always point to the clean hierarchical path (no querystring).\",\"Robots: if query params are present and not whitelisted (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), set noindex. This blocks garbage params from external links.\",\"Pagination: rel=next\u002Fprev generated only for pages > 1 and if resultCount > pageSize.\",\"Stable internal linking: UI links always emit canonical paths; querystring only for user-selected filters.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-extensions\",\"data\":{\"text\":\"Future Extensions Without Bloat\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-extensions\",\"data\":{\"text\":\"We extend by adding providers and presenters, not by stuffing features into the resolver. Booking, reviews, and maps hang off entity pages or dedicated APIs. Discovery remains a list-and-filter surface. That boundary is enforced in code review.\"},\"type\":\"paragraph\"},{\"id\":\"list-extensions\",\"data\":{\"items\":[\"Booking: separate \u002Fapi\u002Fbooking endpoints. Discovery only shows availability badges if cached and non-personal.\",\"Reviews: separate review service\u002Fprovider. Discovery reads aggregated rating fields from GeoIndex (precomputed).\",\"Maps: map tiles and markers fetched from \u002Fapi\u002Fdiscovery\u002Fmarkers with aggressive caching; not embedded into the HTML response if it breaks TTFB.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-went-wrong\",\"data\":{\"text\":\"One Thing That Went Wrong (and What We Changed)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-wrong\",\"data\":{\"text\":\"We shipped the first version with a server cache key that did NOT include tenantId. It “worked” in local. In staging it looked fine. Then production. Portal A started showing Portal B listings on city pages. Same path, different tenant. Cache collision. Ugly.\"},\"type\":\"paragraph\"},{\"id\":\"p-fix\",\"data\":{\"text\":\"Fix was boring but strict: tenantId became mandatory in GeoQuery, and cache key building moved into the resolver so it can’t be skipped. We also added a runtime assertion: if hydrated entities contain a different tenantId, we throw and skip cache write. That’s noisy on purpose.\"},\"type\":\"paragraph\"},{\"id\":\"h-query-plan\",\"data\":{\"text\":\"Query Plan and Provider Contract\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-provider\",\"data\":{\"code\":\"import type { GeoQuery } from '..\u002FGeoQuery';\\n\\nexport type GeoHit = {\\n  entityId: string;\\n  entityType: 'place' | 'service' | 'listing';\\n  lat: number;\\n  lng: number;\\n  citySlug?: string;\\n  countryCode: string;\\n  score?: number;\\n  distanceMeters?: number;\\n};\\n\\nexport type GeoResult = {\\n  hits: GeoHit[];\\n  total: number;\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport interface GeoProvider {\\n  search(query: GeoQuery): Promise\u003CGeoResult>;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"p-queryplan\",\"data\":{\"text\":\"QueryPlan is a small switch on (tenant config + scope kind). Example: some tenants use only SQL; others use a remote portal service. Same GeoQuery input. Same GeoResult output. That makes rollout predictable.\"},\"type\":\"paragraph\"},{\"id\":\"h-sql\",\"data\":{\"text\":\"SQL GeoIndex: Minimal Schema\",\"level\":3},\"type\":\"header\"},{\"id\":\"code-sql\",\"data\":{\"code\":\"CREATE TABLE geo_index (\\n  tenant_id     TEXT NOT NULL,\\n  entity_id     TEXT NOT NULL,\\n  entity_type   TEXT NOT NULL,\\n  country_code  TEXT NOT NULL,\\n  city_slug     TEXT,\\n  lat           DOUBLE PRECISION NOT NULL,\\n  lng           DOUBLE PRECISION NOT NULL,\\n  cell_prefix   TEXT NOT NULL,\\n  category      TEXT,\\n  rating_avg    DOUBLE PRECISION,\\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\\n  PRIMARY KEY (tenant_id, entity_id)\\n);\\n\\nCREATE INDEX geo_index_country_city\\n  ON geo_index (tenant_id, country_code, city_slug);\\n\\nCREATE INDEX geo_index_cell_prefix\\n  ON geo_index (tenant_id, country_code, cell_prefix);\\n\",\"language\":\"sql\"},\"type\":\"code\"},{\"id\":\"h-tradeoffs\",\"data\":{\"text\":\"Trade-offs (Explicit)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-tradeoffs\",\"data\":{\"items\":[\"We trade perfect precision for stable URLs: near pages canonicalize by cellId. Two users 300m apart can land on the same canonical page. Fine.\",\"We trade freshness for cacheability: discovery pages are not real-time. Index updates can lag.\",\"We avoid DB refactor now, but we do pay an extra write path to maintain GeoIndex.\",\"We avoid coupling to CMS routing, but we now own a separate route namespace (\u002Fdiscover). That’s intentional. It’s also a promise we must keep.\",\"We do distance refinement in app code for near searches to avoid expensive DB math. That shifts CPU to the app tier, which is easier to scale horizontally.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-rollout\",\"data\":{\"text\":\"Rollout Plan (Practical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-rollout\",\"data\":{\"items\":[\"Ship resolver + empty pages returning 404 behind a feature flag. Confirm routing doesn’t collide with CMS catch-all.\",\"Build GeoIndex backfill job for one tenant. Validate counts vs source data. Expect mismatches; log them.\",\"Enable country pages only. Watch cache hit ratio and crawl stats.\",\"Enable city pages next. Then near pages last (they are the variant generator).\",\"After we see stable cache keys, add JSON API consumers (maps markers, client filtering).\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-acceptance\",\"data\":{\"text\":\"Acceptance Checklist\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-acceptance\",\"data\":{\"items\":[\"Multi-tenant isolation: same path on two hosts never shares server cache entries.\",\"Canonical URLs do not include query params and remain stable across filter changes.\",\"CMS slug conflicts do not affect \u002Fdiscover routes.\",\"Near pages do not generate unbounded URL permutations (bounded r, pageSize).\",\"Provider contract returns deterministic paging and total counts.\",\"GeoIndex rebuild can run without downtime and without locking source tables for long periods.\"],\"style\":\"unordered\"},\"type\":\"list\"}],\"version\":\"2.29.1\"}",{"time":212,"blocks":558,"version":509},[559,562,565,568,579,582,585,588,597,600,602,605,608,616,619,626,629,632,634,637,640,650,653,656,659,662,665,669,672,675,683,686,689,691,694,697,704,707,714,717,720,726,729,732,735,738,740,743,746,748,751,759,762,770,773],{"id":215,"data":560,"type":42},{"text":561,"level":40},"Geo Discovery: Canonical Architecture, URL Design, Resolver Logic, API & Scalability Spec",{"id":219,"data":563,"type":222},{"text":564},"This document specifies the geo-based discovery surface (country\u002Fcity\u002Fradius) across multiple portals (multi-tenant), without forcing immediate DB refactoring, and without coupling to CMS routing (page\u002Fpost). It keeps SEO stable, stays cache-friendly, and leaves room for booking\u002Freviews\u002Fmaps without turning the app into a blob.",{"id":224,"data":566,"type":42},{"text":567,"level":227},"Constraints and Non-Goals",{"id":229,"data":569,"type":241},{"items":570,"style":240},[571,572,573,574,575,576,577,578],"Multi-tenant: same codebase serves multiple portals. Tenant affects content scope, branding, and sometimes data source.","Geo discovery must support: country, city, radius search (around a point).","No immediate database refactoring: we cannot reshape existing tables into a perfect geo schema right now.","Independence from CMS routing: geo pages are not \"posts\" or \"pages\". They cannot be blocked by CMS slug conflicts.","SEO stability: canonical URLs must not change when filters\u002Fsort options change.","Cache friendliness: CDN + server cache should have predictable keys. Avoid per-user variation.","Strict separation of concerns: discovery, CMS, and tenant resolution are separate modules with explicit boundaries.","Non-goal (for now): perfect geocoding. We accept one geocoder, one normalization strategy, and we store the normalized result.",{"id":243,"data":580,"type":42},{"text":581,"level":227},"Canonical Architecture",{"id":247,"data":583,"type":222},{"text":584},"We implement geo discovery as its own bounded context with a small public surface: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> SEO + Cache metadata. CMS routes never call into discovery. Discovery never calls CMS routing. They share only low-level utilities (HTTP, caching, tenant context).",{"id":251,"data":586,"type":42},{"text":587,"level":254},"Key Components",{"id":256,"data":589,"type":241},{"items":590,"style":240},[591,592,593,594,595,596],"TenantContext: resolves tenant from host header (or explicit portal id in internal calls).","GeoResolver: parses + validates geo URL segments; emits a normalized GeoQuery.","GeoIndex (Read Model): a separate table\u002Fcollection that maps entityId -> lat\u002Flng + tenant scope + minimal searchable fields. This avoids refactoring source DB tables.","Data Providers: pluggable sources (e.g., existing SQL tables, WordPress API, another portal service). They are behind an interface.","SEO Router: produces canonical URL and meta (canonical, hreflang if needed, robots flags).","Caching Layer: CDN cache keys + server-side cache with tenant-scoped keys and versioning.",{"id":266,"data":598,"type":42},{"text":599,"level":227},"Folder Structure",{"id":270,"data":601,"type":274},{"code":272,"language":273},{"id":276,"data":603,"type":42},{"text":604,"level":227},"URL Design (SEO Canonical)",{"id":280,"data":606,"type":222},{"text":607},"We keep the canonical URL purely hierarchical and human-readable. Filters\u002Fsort stay in querystring but do NOT affect the canonical. Radius search uses a stable \"near\" page with a canonical based on a rounded coordinate cell, not the raw lat\u002Flng. That prevents infinite URL variants. It also prevents cache explosion. It’s a deliberate compromise.",{"id":284,"data":609,"type":241},{"items":610,"style":240},[611,612,613,614,615],"Country landing: \u002Fdiscover\u002F{countryCode} (example: \u002Fdiscover\u002Fde)","City landing: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (example: \u002Fdiscover\u002Fde\u002Fmunich)","Near (radius): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (example: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) where cellId is a short geohash-ish identifier","Optional query params (non-canonical): ?q=shih-tzu&category=pet-care&sort=rating&page=2","Tenant is NOT in the path. Tenant is the host (portal-a.tld, portal-b.tld). Internal calls pass x-tenant-id.",{"id":293,"data":617,"type":42},{"text":618,"level":254},"Canonical Rules",{"id":297,"data":620,"type":241},{"items":621,"style":240},[622,623,624,625],"Country\u002Fcity pages canonicalize to their own clean path (no querystring).","Near pages canonicalize to \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. The canonical is derived from a rounded cell, not the incoming coordinates.","page=1 is omitted from canonical and from internal link generation.","Unsupported combinations (e.g., country mismatch) return 404, not a redirect. Redirect chains were hurting crawl budget in testing.",{"id":305,"data":627,"type":42},{"text":628,"level":227},"Resolver Logic",{"id":309,"data":630,"type":222},{"text":631},"Resolver input is (tenant, pathname, query). Resolver output is a normalized GeoQuery with a query plan. No DB access inside the resolver. That separation mattered later. It saved us from a nasty caching bug.",{"id":313,"data":633,"type":274},{"code":315,"language":316},{"id":318,"data":635,"type":274},{"code":636,"language":316},"import { z } from 'zod';\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\n\nconst QuerySchema = z.object({\n  q: z.string().trim().min(1).max(120).optional(),\n  category: z.string().trim().min(1).max(60).optional(),\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\n  page: z.coerce.number().int().min(1).max(200).default(1),\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\n});\n\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\n\nexport function resolveGeo(\n  tenantId: TenantId,\n  pathname: string,\n  query: Record\u003Cstring, unknown>\n): GeoQuery {\n  const filters = QuerySchema.parse(query);\n  const parts = pathname.split('\u002F').filter(Boolean);\n\n  \u002F\u002F \u002Fdiscover\u002F{country}\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\n  if (parts[0] !== 'discover') {\n    throw new Error('Not a discovery route');\n  }\n\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\n\n  let scope: GeoQuery['scope'];\n  if (parts.length === 2) {\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\n  } else if (parts[2] === 'near') {\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\n    \u002F\u002F radius is NOT in the path, but is bounded.\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\n  } else {\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\n  }\n\n  const canonicalPath = canonicalizePath(scope);\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\n\n  return {\n    tenantId,\n    scope,\n    filters: {\n      ...filters,\n      sort: filters.sort ?? 'relevance'\n    },\n    canonicalPath,\n    cacheKey\n  };\n}\n\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\n  switch (scope.kind) {\n    case 'country':\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\n    case 'city':\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\n    case 'near':\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\n  }\n}\n\nfunction buildCacheKey(\n  tenantId: string,\n  scope: GeoQuery['scope'],\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\n): string {\n  \u002F\u002F Note: canonical ignores querystring, cache does not.\n  \u002F\u002F But we keep it bounded and explicit.\n  const q = filters.q ? `q=${filters.q}` : '';\n  const c = filters.category ? `cat=${filters.category}` : '';\n  const s = `sort=${filters.sort ?? 'relevance'}`;\n  const p = `p=${filters.page}`;\n  const ps = `ps=${filters.pageSize}`;\n\n  const scopeKey =\n    scope.kind === 'country'\n      ? `country:${scope.countryCode}`\n      : scope.kind === 'city'\n        ? `city:${scope.countryCode}:${scope.citySlug}`\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\n\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\n}\n",{"id":322,"data":638,"type":42},{"text":639,"level":227},"Step-by-Step Request Flow",{"id":326,"data":641,"type":241},{"items":642,"style":336},[643,644,645,646,647,648,649],"Edge\u002FCDN receives request. Cache key includes host + path + bounded query params (q, category, sort, page, pageSize, r).","App server creates TenantContext from Host header. No DB yet.","GeoResolver parses URL. Produces GeoQuery with canonicalPath and server cacheKey.","GeoController builds a QueryPlan. It decides which provider(s) to hit based on tenant config and scope kind.","Provider executes read-model query against GeoIndex (fast). Then hydrates results from the existing source DB\u002FAPI using entity IDs (no refactor required).","Response assembler attaches SEO metadata: canonical, robots, pagination links.","Server sets cache headers (s-maxage + stale-while-revalidate). Body is tenant-scoped, never shared across tenants.",{"id":338,"data":651,"type":42},{"text":652,"level":227},"No-Refactor Strategy: GeoIndex Read Model",{"id":342,"data":654,"type":222},{"text":655},"We cannot restructure existing content tables right now. So we add a separate GeoIndex that we can rebuild independently. It stores just enough to do discovery efficiently: tenantId, entityId, entityType, countryCode, citySlug, lat, lng, and a few filter fields. Hydration fetches the full object from the current source (SQL rows, CMS API, etc.).",{"id":346,"data":657,"type":222},{"text":658},"Trade-off: eventual consistency. Index rebuild lag is acceptable for discovery pages. We set SLA: updates appear within 15 minutes. If we need real-time later (booking availability), that’s a different surface and should not reuse the discovery cache.",{"id":350,"data":660,"type":42},{"text":661,"level":227},"API Surface",{"id":354,"data":663,"type":222},{"text":664},"Two surfaces: (A) HTML pages for SEO and users, (B) JSON API for client rendering and future extensions. Same resolver + same query plan. Different presenters.",{"id":358,"data":666,"type":241},{"items":667,"style":240},[361,362,668],"Admin (internal): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protected), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, later)",{"id":365,"data":670,"type":274},{"code":671,"language":316},"import type { IncomingMessage, ServerResponse } from 'http';\nimport { resolveGeo } from '.\u002FGeoResolver';\nimport { runQueryPlan } from '.\u002FGeoController';\n\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\n\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\n\n  \u002F\u002F We reuse the same resolver by mapping query -> a pseudo-path.\n  \u002F\u002F This keeps logic aligned between HTML and API.\n  const scope = url.searchParams.get('scope') ?? 'country';\n  const country = url.searchParams.get('country') ?? '';\n  const city = url.searchParams.get('city');\n  const cell = url.searchParams.get('cell');\n\n  const pseudoPath =\n    scope === 'city' && city\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\n      : scope === 'near' && cell\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\n        : `\u002Fdiscover\u002F${country}`;\n\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\n  const result = await runQueryPlan(geoQuery);\n\n  res.statusCode = 200;\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\n  \u002F\u002F cache: public at CDN, tenant-specific key already handled upstream\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\n\n  res.end(JSON.stringify(result));\n}\n",{"id":369,"data":673,"type":42},{"text":674,"level":227},"Scalability & Performance",{"id":373,"data":676,"type":241},{"items":677,"style":240},[678,679,680,681,682],"Primary performance goal: serve cached discovery HTML from CDN for popular geo landings (country\u002Fcity).","Near pages are cacheable but have more variants (cellId + r + q + category + sort + page). We bound r, pageSize, and validate filters strictly.","GeoIndex query must be fast: use tenantId + countryCode + citySlug indexes; for near use cell buckets (prefix match) then refine by distance in app.","Avoid expensive SQL haversine over large datasets. It looks simple. It is not.","Hydration is batched by entityId list. One query per entityType, not N+1.",{"id":382,"data":684,"type":42},{"text":685,"level":254},"Radius Search Strategy (Near Pages)",{"id":386,"data":687,"type":222},{"text":688},"We do not run a full radius scan over all rows. Instead: (1) cellId maps to a bounding bucket (geohash-ish prefix), (2) fetch candidates from GeoIndex by bucket prefix, (3) refine in application with haversine distance, (4) sort + paginate. This keeps DB load predictable.",{"id":390,"data":690,"type":274},{"code":392,"language":316},{"id":394,"data":692,"type":42},{"text":693,"level":227},"Caching Model (CDN + Server)",{"id":398,"data":695,"type":222},{"text":696},"We cache at two layers. CDN caches the full HTML\u002FJSON for anonymous traffic. Server cache stores provider results keyed by GeoQuery.cacheKey. The key includes tenant, scope, and bounded filters. Not everything should be cached. Booking availability later will be excluded.",{"id":402,"data":698,"type":241},{"items":699,"style":240},[700,701,702,703],"CDN cache key varies by Host header. That is the tenant boundary.","Server cache key includes tenantId explicitly. Never rely on implicit host in-process.","Cache TTLs: country\u002Fcity 30–60 minutes at CDN (stale-while-revalidate enabled). near pages 5 minutes.","We keep a manual bust lever per tenant (version suffix in cache key). Used during migrations and bad deploys.",{"id":410,"data":705,"type":42},{"text":706,"level":227},"SEO Stability Details",{"id":414,"data":708,"type":241},{"items":709,"style":240},[710,711,712,713],"Canonical tags: always point to the clean hierarchical path (no querystring).","Robots: if query params are present and not whitelisted (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), set noindex. This blocks garbage params from external links.","Pagination: rel=next\u002Fprev generated only for pages > 1 and if resultCount > pageSize.","Stable internal linking: UI links always emit canonical paths; querystring only for user-selected filters.",{"id":422,"data":715,"type":42},{"text":716,"level":227},"Future Extensions Without Bloat",{"id":426,"data":718,"type":222},{"text":719},"We extend by adding providers and presenters, not by stuffing features into the resolver. Booking, reviews, and maps hang off entity pages or dedicated APIs. Discovery remains a list-and-filter surface. That boundary is enforced in code review.",{"id":430,"data":721,"type":241},{"items":722,"style":240},[723,724,725],"Booking: separate \u002Fapi\u002Fbooking endpoints. Discovery only shows availability badges if cached and non-personal.","Reviews: separate review service\u002Fprovider. Discovery reads aggregated rating fields from GeoIndex (precomputed).","Maps: map tiles and markers fetched from \u002Fapi\u002Fdiscovery\u002Fmarkers with aggressive caching; not embedded into the HTML response if it breaks TTFB.",{"id":437,"data":727,"type":42},{"text":728,"level":227},"One Thing That Went Wrong (and What We Changed)",{"id":441,"data":730,"type":222},{"text":731},"We shipped the first version with a server cache key that did NOT include tenantId. It “worked” in local. In staging it looked fine. Then production. Portal A started showing Portal B listings on city pages. Same path, different tenant. Cache collision. Ugly.",{"id":445,"data":733,"type":222},{"text":734},"Fix was boring but strict: tenantId became mandatory in GeoQuery, and cache key building moved into the resolver so it can’t be skipped. We also added a runtime assertion: if hydrated entities contain a different tenantId, we throw and skip cache write. That’s noisy on purpose.",{"id":449,"data":736,"type":42},{"text":737,"level":227},"Query Plan and Provider Contract",{"id":453,"data":739,"type":274},{"code":455,"language":316},{"id":457,"data":741,"type":222},{"text":742},"QueryPlan is a small switch on (tenant config + scope kind). Example: some tenants use only SQL; others use a remote portal service. Same GeoQuery input. Same GeoResult output. That makes rollout predictable.",{"id":461,"data":744,"type":42},{"text":745,"level":254},"SQL GeoIndex: Minimal Schema",{"id":465,"data":747,"type":274},{"code":467,"language":468},{"id":470,"data":749,"type":42},{"text":750,"level":227},"Trade-offs (Explicit)",{"id":474,"data":752,"type":241},{"items":753,"style":240},[754,755,756,757,758],"We trade perfect precision for stable URLs: near pages canonicalize by cellId. Two users 300m apart can land on the same canonical page. Fine.","We trade freshness for cacheability: discovery pages are not real-time. Index updates can lag.","We avoid DB refactor now, but we do pay an extra write path to maintain GeoIndex.","We avoid coupling to CMS routing, but we now own a separate route namespace (\u002Fdiscover). That’s intentional. It’s also a promise we must keep.","We do distance refinement in app code for near searches to avoid expensive DB math. That shifts CPU to the app tier, which is easier to scale horizontally.",{"id":483,"data":760,"type":42},{"text":761,"level":227},"Rollout Plan (Practical)",{"id":487,"data":763,"type":241},{"items":764,"style":336},[765,766,767,768,769],"Ship resolver + empty pages returning 404 behind a feature flag. Confirm routing doesn’t collide with CMS catch-all.","Build GeoIndex backfill job for one tenant. Validate counts vs source data. Expect mismatches; log them.","Enable country pages only. Watch cache hit ratio and crawl stats.","Enable city pages next. Then near pages last (they are the variant generator).","After we see stable cache keys, add JSON API consumers (maps markers, client filtering).",{"id":496,"data":771,"type":42},{"text":772,"level":227},"Acceptance Checklist",{"id":500,"data":774,"type":241},{"items":775,"style":240},[776,777,778,779,780,781],"Multi-tenant isolation: same path on two hosts never shares server cache entries.","Canonical URLs do not include query params and remain stable across filter changes.","CMS slug conflicts do not affect \u002Fdiscover routes.","Near pages do not generate unbounded URL permutations (bounded r, pageSize).","Provider contract returns deterministic paging and total counts.","GeoIndex rebuild can run without downtime and without locking source tables for long periods.","Geo-based discovery architecture for multi-tenant portals. Defines canonical URLs, resolver logic, caching strategy, and a geo read-model without CMS coupling or database refactoring. Designed for SEO stability, scalability, and future extensions like booking and maps.",{"lang":7,"title":208,"content":210,"contentJson":784,"excerpt":510},{"time":212,"blocks":785,"version":509},[786,788,790,792,795,797,799,801,804,806,808,810,812,815,817,820,822,824,826,828,830,833,835,837,839,841,843,846,848,850,853,855,857,859,861,863,866,868,871,873,875,878,880,882,884,886,888,890,892,894,896,899,901,904,906],{"id":215,"data":787,"type":42},{"text":217,"level":40},{"id":219,"data":789,"type":222},{"text":221},{"id":224,"data":791,"type":42},{"text":226,"level":227},{"id":229,"data":793,"type":241},{"items":794,"style":240},[232,233,234,235,236,237,238,239],{"id":243,"data":796,"type":42},{"text":245,"level":227},{"id":247,"data":798,"type":222},{"text":249},{"id":251,"data":800,"type":42},{"text":253,"level":254},{"id":256,"data":802,"type":241},{"items":803,"style":240},[259,260,261,262,263,264],{"id":266,"data":805,"type":42},{"text":268,"level":227},{"id":270,"data":807,"type":274},{"code":272,"language":273},{"id":276,"data":809,"type":42},{"text":278,"level":227},{"id":280,"data":811,"type":222},{"text":282},{"id":284,"data":813,"type":241},{"items":814,"style":240},[287,288,289,290,291],{"id":293,"data":816,"type":42},{"text":295,"level":254},{"id":297,"data":818,"type":241},{"items":819,"style":240},[300,301,302,303],{"id":305,"data":821,"type":42},{"text":307,"level":227},{"id":309,"data":823,"type":222},{"text":311},{"id":313,"data":825,"type":274},{"code":315,"language":316},{"id":318,"data":827,"type":274},{"code":320,"language":316},{"id":322,"data":829,"type":42},{"text":324,"level":227},{"id":326,"data":831,"type":241},{"items":832,"style":336},[329,330,331,332,333,334,335],{"id":338,"data":834,"type":42},{"text":340,"level":227},{"id":342,"data":836,"type":222},{"text":344},{"id":346,"data":838,"type":222},{"text":348},{"id":350,"data":840,"type":42},{"text":352,"level":227},{"id":354,"data":842,"type":222},{"text":356},{"id":358,"data":844,"type":241},{"items":845,"style":240},[361,362,363],{"id":365,"data":847,"type":274},{"code":367,"language":316},{"id":369,"data":849,"type":42},{"text":371,"level":227},{"id":373,"data":851,"type":241},{"items":852,"style":240},[376,377,378,379,380],{"id":382,"data":854,"type":42},{"text":384,"level":254},{"id":386,"data":856,"type":222},{"text":388},{"id":390,"data":858,"type":274},{"code":392,"language":316},{"id":394,"data":860,"type":42},{"text":396,"level":227},{"id":398,"data":862,"type":222},{"text":400},{"id":402,"data":864,"type":241},{"items":865,"style":240},[405,406,407,408],{"id":410,"data":867,"type":42},{"text":412,"level":227},{"id":414,"data":869,"type":241},{"items":870,"style":240},[417,418,419,420],{"id":422,"data":872,"type":42},{"text":424,"level":227},{"id":426,"data":874,"type":222},{"text":428},{"id":430,"data":876,"type":241},{"items":877,"style":240},[433,434,435],{"id":437,"data":879,"type":42},{"text":439,"level":227},{"id":441,"data":881,"type":222},{"text":443},{"id":445,"data":883,"type":222},{"text":447},{"id":449,"data":885,"type":42},{"text":451,"level":227},{"id":453,"data":887,"type":274},{"code":455,"language":316},{"id":457,"data":889,"type":222},{"text":459},{"id":461,"data":891,"type":42},{"text":463,"level":254},{"id":465,"data":893,"type":274},{"code":467,"language":468},{"id":470,"data":895,"type":42},{"text":472,"level":227},{"id":474,"data":897,"type":241},{"items":898,"style":240},[477,478,479,480,481],{"id":483,"data":900,"type":42},{"text":485,"level":227},{"id":487,"data":902,"type":241},{"items":903,"style":336},[490,491,492,493,494],{"id":496,"data":905,"type":42},{"text":498,"level":227},{"id":500,"data":907,"type":241},{"items":908,"style":240},[503,504,505,506,507,508],"Post erfolgreich abgerufen",{"items":911,"source":947,"manualIds":948,"manualMatchedIds":949},[912,919,926,933,940],{"id":913,"slug":914,"title":915,"excerpt":916,"featuredImage":917,"publishedAt":918},"454","zbt-z8102ax-rm500u-ea-5g-modem-test","Quectel RM500U-EA en el ZBT Z8102AX: Bandas 5G, o2 Alemania y comportamiento de la señal en el mundo real","El ZBT Z8102AX utiliza un módem Quectel RM500U-EA para conectividad 4G y 5G. En la primera prueba práctica, el router se conectó con éxito a o2 Alemania con LTE Banda 3 y NR n28. El módem funciona, pero diagnósticos más profundos como RSRP, RSRQ, SINR, bloqueo de bandas y comportamiento de la celda aún necesitan pruebas adecuadas.","\u002Fuploads\u002F2026\u002F06\u002Fopenwrt-router-review-dual-sim-06-1781620597879-qay2sx.webp","2026-06-16T08:39:00.000Z",{"id":920,"slug":921,"title":922,"excerpt":923,"featuredImage":924,"publishedAt":925},"361","model-view-controller-mvc","Model-View-Controller (MVC): La columna vertebral estructural de las aplicaciones web modernas","Modelo-Vista-Controlador, generalmente abreviado como MVC, sigue siendo uno de los patrones arquitectónicos más duraderos en el desarrollo de software. Ofrece a los equipos una forma práctica de separar la lógica de negocio, la presentación y la interacción del usuario para que las aplicaciones sigan siendo más fáciles de construir, ampliar, probar y mantener. Este artículo explica qué es MVC, por qué sigue siendo importante, dónde encaja en los stacks web actuales y cómo se conecta con la arquitectura de plataforma más amplia, la calidad de la entrega, la estrategia de migración y la madurez operativa.","\u002Fuploads\u002F2026\u002F03\u002Fmodel-view-controller-mvc-1774872805793-0bjubu.webp","2023-04-12T12:57:00.000Z",{"id":927,"slug":928,"title":929,"excerpt":930,"featuredImage":931,"publishedAt":932},"456","zbt-z8102ax-hardware-packaging-review","Reseña de hardware y embalaje del ZBT Z8102AX: Router fuerte, caja débil","El ZBT Z8102AX causa una primera impresión sólida como un delgado router OpenWrt 5G de metal negro con múltiples conectores de antena, ranuras para doble SIM, puertos USB, LAN\u002FWAN y un práctico juego de accesorios. El hardware se siente útil y serio, pero el embalaje es claramente el punto débil.","\u002Fuploads\u002F2026\u002F06\u002Fopenwrt-router-review-dual-sim-02-1781620590938-y33j4b.webp","2026-06-16T04:40:00.000Z",{"id":934,"slug":935,"title":936,"excerpt":937,"featuredImage":938,"publishedAt":939},"471","how-to-know-whether-an-ai-agent-actually-used-the-right-evidence","Cómo saber si un agente de IA realmente utilizó la evidencia correcta","Un agente de IA puede citar fuentes y aun así usar la evidencia incorrecta. Este artículo presenta un método práctico para verificar el respaldo de las afirmaciones, la autoridad de la fuente, la aplicabilidad, la procedencia y si la evidencia realmente influyó en la respuesta.","\u002Fuploads\u002F2026\u002F09\u002Fhow-to-know-whether-an-ai-agent-actually-used-the-right-evidence-1790351317188-o5z9ve.webp","2026-09-25T11:47:00.000Z",{"id":941,"slug":942,"title":943,"excerpt":944,"featuredImage":945,"publishedAt":946},"381","enterprise-grade-multi-tenant-architecture-for-an-international-platform","Arquitectura Multi-Inquilino de Grado Empresarial para una Plataforma Internacional","Loving Rocks es una plataforma de bodas de nivel empresarial diseñada con una verdadera arquitectura multiinquilino, bases de datos aisladas por inquilino e internacionalización integrada para escalabilidad global, seguridad y estabilidad operativa a largo plazo.","\u002Fuploads\u002F2026\u002F01\u002Fenterprise-grade-multi-tenant-architecture-for-an-international-platform-1769789121298-b6v7ak.webp","2026-01-30T12:04:00.000Z","fallback",[],[]]