import type { SearchHit, SearchResponse } from "@dci/core"; import { pg, likePattern, projectLive } from "../lib/sql.js"; import { int, num, reqStr, str, type Row } from "../lib/rows.js"; import { hrefFor } from "../lib/resolve.js"; import { hasFilters, interpretQuery, type InterpretOptions } from "../search/interpret.js"; import { operatorCandidates } from "./operators.js"; import { listFacilities, searchCities } from "./facilities.js"; import { listProjects } from "./projects.js"; import { searchCountries } from "./countries.js"; const MAX_HITS = 30; async function facilityHits(text: string, limit = 12): Promise { const sql = pg(); const rows = await sql` select f.id, f.slug, f.name, f.city, f.country_iso2, f.status, f.lat, f.lng, o.name as op_name, greatest(ts_rank(f.search, plainto_tsquery('simple', ${text})), similarity(f.name, ${text}), case when lower(f.name) = lower(${text}) then 1.2 when f.name ilike ${likePattern(text)} then 0.7 else 0 end) as score from facilities f left join operators o on o.id = f.operator_id where f.merged_into is null and (f.search @@ plainto_tsquery('simple', ${text}) or f.name % ${text} or f.name ilike ${likePattern(text)} or exists (select 1 from facility_aliases a where a.facility_id = f.id and a.alias ilike ${likePattern(text)})) order by score desc, f.completeness desc limit ${limit}`; return rows.map((r) => ({ type: "facility", id: reqStr(r.id), slug: reqStr(r.slug), title: reqStr(r.name), subtitle: [str(r.op_name), str(r.city), str(r.country_iso2)].filter(Boolean).join(" · ") || null, href: hrefFor("facility", reqStr(r.slug)), score: Math.min(1, 0.55 + 0.4 * (num(r.score) ?? 0)), meta: { status: str(r.status), countryIso2: str(r.country_iso2), lat: num(r.lat), lng: num(r.lng) } })); } async function operatorHits(text: string, limit = 5): Promise { const sql = pg(); const rows = await sql` select o.id, o.slug, o.name, o.kind, o.hq_country_iso2, greatest(similarity(o.name, ${text}), case when lower(o.name) = lower(${text}) then 1.2 when o.name ilike ${likePattern(text)} then 0.8 else 0 end) as score, (select count(*)::int from facilities f where f.operator_id = o.id and f.merged_into is null) as n from operators o where o.name % ${text} or o.name ilike ${likePattern(text)} or exists (select 1 from unnest(o.aliases) a where a ilike ${likePattern(text)}) order by score desc, n desc limit ${limit}`; return rows.map((r) => ({ type: "operator", id: reqStr(r.id), slug: reqStr(r.slug), title: reqStr(r.name), subtitle: [str(r.kind), int(r.n) ? `${int(r.n)} facilities` : null].filter(Boolean).join(" · ") || null, href: hrefFor("operator", reqStr(r.slug)), score: Math.min(1, 0.6 + 0.35 * (num(r.score) ?? 0)), meta: { facilityCount: int(r.n), hqCountryIso2: str(r.hq_country_iso2) } })); } async function metroHits(text: string, limit = 4): Promise { const sql = pg(); const rows = await sql` select m.id, m.slug, m.name, m.country_iso2, m.lat, m.lng, m.radius_km, greatest(similarity(m.name, ${text}), case when lower(m.name) = lower(${text}) then 1.2 when m.name ilike ${likePattern(text)} then 0.8 else 0 end) as score, (select count(*)::int from facilities f where f.metro_id = m.id and f.merged_into is null) as n from metros m where m.name % ${text} or m.name ilike ${likePattern(text)} or exists (select 1 from unnest(m.aliases) a where a ilike ${likePattern(text)}) order by score desc, n desc limit ${limit}`; return rows.map((r) => ({ type: "metro", id: reqStr(r.id), slug: reqStr(r.slug), title: reqStr(r.name), subtitle: `${reqStr(r.country_iso2)} market${int(r.n) ? ` · ${int(r.n)} facilities` : ""}`, href: hrefFor("metro", reqStr(r.slug)), score: Math.min(1, 0.65 + 0.3 * (num(r.score) ?? 0)), meta: { facilityCount: int(r.n), countryIso2: str(r.country_iso2), lat: num(r.lat), lng: num(r.lng), radiusKm: num(r.radius_km) } })); } async function cloudRegionHits(text: string, limit = 4): Promise { const sql = pg(); const rows = await sql` select r.id, r.slug, r.code, r.name, r.city, r.country_iso2, pr.name as pr_name, case when lower(r.code) = lower(${text}) then 1.2 when r.code ilike ${likePattern(text)} then 0.8 else similarity(r.name, ${text}) end as score from cloud_regions r join operators pr on pr.id = r.provider_id where r.code ilike ${likePattern(text)} or r.name ilike ${likePattern(text)} or r.name % ${text} order by score desc limit ${limit}`; return rows.map((r) => ({ type: "cloud_region", id: reqStr(r.id), slug: reqStr(r.slug), title: `${reqStr(r.pr_name)} ${reqStr(r.code)}`, subtitle: [str(r.name), str(r.city), str(r.country_iso2)].filter(Boolean).join(" · ") || null, href: hrefFor("cloud_region", reqStr(r.slug)), score: Math.min(1, 0.5 + 0.4 * (num(r.score) ?? 0)) })); } async function projectHits(text: string, limit = 5): Promise { const sql = pg(); const rows = await sql` select p.id, p.slug, p.name, p.status, p.country_iso2, p.planned_mw, o.name as op_name, greatest(similarity(p.name, ${text}), case when p.name ilike ${likePattern(text)} then 0.7 else 0 end) as score from projects p left join operators o on o.id = p.operator_id where ${projectLive(sql)} and (p.name % ${text} or p.name ilike ${likePattern(text)}) order by score desc, p.last_update desc limit ${limit}`; return rows.map((r) => ({ type: "project", id: reqStr(r.id), slug: reqStr(r.slug), title: reqStr(r.name), subtitle: [str(r.op_name), str(r.status)?.replace(/_/g, " "), num(r.planned_mw) != null ? `${num(r.planned_mw)} MW` : null, str(r.country_iso2)].filter(Boolean).join(" · ") || null, href: hrefFor("project", reqStr(r.slug)), score: Math.min(1, 0.45 + 0.4 * (num(r.score) ?? 0)) })); } async function ixpHits(text: string, limit = 3): Promise { const sql = pg(); const rows = await sql` select x.id, x.slug, x.name, x.name_long, x.city, x.country_iso2, case when lower(x.name) = lower(${text}) then 1.2 when x.name ilike ${likePattern(text)} then 0.8 else 0.5 end as score from ixps x where x.name ilike ${likePattern(text)} or x.name_long ilike ${likePattern(text)} order by score desc, x.network_count desc nulls last limit ${limit}`; return rows.map((r) => ({ type: "ixp", id: reqStr(r.id), slug: reqStr(r.slug), title: reqStr(r.name), subtitle: [str(r.name_long), str(r.city), str(r.country_iso2)].filter(Boolean).join(" · ") || null, href: hrefFor("ixp", reqStr(r.slug)), score: Math.min(1, 0.4 + 0.4 * (num(r.score) ?? 0)) })); } /** Metro candidates for query interpretation (trigram on name, substring on aliases). */ async function metroCandidates(q: string, limit = 5): Promise> { const sql = pg(); const rows = await sql` select m.slug, m.name, m.aliases from metros m where position(lower(m.name) in lower(${q})) > 0 or (m.name % ${q} and similarity(m.name, ${q}) >= 0.5) or exists (select 1 from unnest(m.aliases) a where length(a) >= 3 and position(lower(a) in lower(${q})) > 0) order by similarity(m.name, ${q}) desc limit ${limit}`; return rows.map((r) => ({ slug: reqStr(r.slug), name: reqStr(r.name), aliases: Array.isArray(r.aliases) ? (r.aliases as string[]) : [] })); } /** Facility ids behind a search response (hits + list), for the envelope `sources`. */ export function facilityIdsOf(res: SearchResponse): string[] { const ids = new Set(); for (const h of res.hits) if (h.type === "facility") ids.add(h.id); for (const f of res.facilities?.items ?? []) ids.add(f.id); return [...ids]; } export async function search(q: string): Promise { const query = q.replace(/\s+/g, " ").trim().slice(0, 200); if (!query) return { query: "", hits: [] }; const [ops, metros] = await Promise.all([operatorCandidates(query, 5).catch(() => []), metroCandidates(query, 5).catch(() => [])]); const interpreted = interpretQuery(query, { operators: ops, metros }); const text = interpreted.text ?? ""; const hits: SearchHit[] = []; const tasks: Promise[] = []; if (text) { tasks.push(facilityHits(text), operatorHits(text), metroHits(text), cloudRegionHits(text), projectHits(text), ixpHits(text)); tasks.push(searchCities(text, 4).then((cities) => cities.map((c) => ({ type: "city" as const, id: `city:${c.city}:${c.countryIso2 ?? ""}`, slug: c.city, title: c.city, subtitle: `${c.countryIso2 ?? ""}${c.countryIso2 ? " · " : ""}${c.count} facilit${c.count > 1 ? "ies" : "y"}`, href: `/datacenters?q=${encodeURIComponent(c.city)}${c.countryIso2 ? `&country=${c.countryIso2}` : ""}`, score: 0.72 + Math.min(0.15, c.count / 200), meta: { count: c.count, countryIso2: c.countryIso2 } })))); tasks.push(searchCountries(text, 3).then((cs) => cs.map((c) => ({ type: "country" as const, id: c.iso2, slug: c.slug, title: c.name, subtitle: `${c.facilityCount} facilities`, href: hrefFor("country", c.slug), score: c.exact ? 0.98 : 0.7, meta: { facilityCount: c.facilityCount } })))); } // structured hits for the interpreted filters (country / operator) if (interpreted.countryIso2) tasks.push(searchCountries(interpreted.countryIso2, 1).then((cs) => cs.map((c) => ({ type: "country" as const, id: c.iso2, slug: c.slug, title: c.name, subtitle: `${c.facilityCount} facilities`, href: hrefFor("country", c.slug), score: 0.99, meta: { facilityCount: c.facilityCount } })))); if (interpreted.operator) { const op = ops.find((o) => o.slug === interpreted.operator); if (op) hits.push({ type: "operator", id: op.id, slug: op.slug, title: op.name, subtitle: "Operator", href: hrefFor("operator", op.slug), score: 0.97 }); } if (interpreted.metro) { const m = metros.find((x) => x.slug === interpreted.metro); if (m) hits.push({ type: "metro", id: `metro:${m.slug}`, slug: m.slug, title: m.name, subtitle: "Market", href: hrefFor("metro", m.slug), score: 0.96 }); } const results = await Promise.allSettled(tasks); for (const r of results) if (r.status === "fulfilled") hits.push(...r.value); // dedupe by type+id const seen = new Set(); const merged = hits.filter((h) => { const k = `${h.type}:${h.id}`; if (seen.has(k)) return false; seen.add(k); return true; }).sort((a, b) => b.score - a.score).slice(0, MAX_HITS); const response: SearchResponse = { query, interpreted, hits: merged.map((h) => ({ ...h, score: Math.round(h.score * 1000) / 1000 })) }; const yearFrom = interpreted.year != null && interpreted.yearOp !== "before" ? interpreted.year : undefined; const yearTo = interpreted.year != null && interpreted.yearOp !== "after" ? interpreted.year : undefined; if (interpreted.entity === "project") { // project intent: rank project rows instead of facilities (planned MW, status, country, AI, expected year) const pl = await listProjects({ q: text || undefined, country: interpreted.countryIso2, operator: interpreted.operator, metro: interpreted.metro, status: interpreted.status ? [interpreted.status] : undefined, min_mw: interpreted.minMw, ai: interpreted.ai ? true : undefined, per_page: 20, page: 1, sort: text ? "updated" : "mw", order: "desc" }).catch(() => ({ items: [], total: 0 })); const filtered = pl.items.filter((p) => (interpreted.maxMw == null || p.plannedMw == null || p.plannedMw <= interpreted.maxMw) && (yearFrom == null || !p.expectedOpening || Number(p.expectedOpening.slice(0, 4)) >= yearFrom) && (yearTo == null || !p.expectedOpening || Number(p.expectedOpening.slice(0, 4)) <= yearTo)); const seenP = new Set(response.hits.filter((h) => h.type === "project").map((h) => h.id)); for (const p of filtered) { if (seenP.has(p.id)) continue; response.hits.push({ type: "project", id: p.id, slug: p.slug, title: p.name, subtitle: [p.operator?.name, p.status.replace(/_/g, " "), p.plannedMw != null ? `${p.plannedMw} MW` : null, p.expectedOpening ? `opening ${p.expectedOpening}` : null, p.countryIso2].filter(Boolean).join(" · ") || null, href: hrefFor("project", p.slug), score: 0.9, meta: { status: p.status, plannedMw: p.plannedMw, countryIso2: p.countryIso2 } }); } response.hits = response.hits.sort((a, b) => b.score - a.score).slice(0, MAX_HITS); return response; } if (hasFilters(interpreted) || text) { const fl = await listFacilities({ q: text || undefined, country: interpreted.countryIso2 ? [interpreted.countryIso2] : undefined, operator: interpreted.operator, metro: interpreted.metro, status: interpreted.status ? [interpreted.status] : undefined, type: interpreted.facilityType && interpreted.facilityType !== "cloud_region" && interpreted.facilityType !== "ai" ? [interpreted.facilityType] : undefined, ai: interpreted.ai || interpreted.facilityType === "ai" ? true : undefined, min_mw: interpreted.minMw, max_mw: interpreted.maxMw, opened_from: yearFrom, opened_to: yearTo, per_page: 20, page: 1, sort: text ? "completeness" : "mw", order: "desc" }); response.facilities = { total: fl.total, items: fl.items }; } return response; }