SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
12.8 KB · 137 lines typescript
Raw Blame History
1import type { SearchHit, SearchResponse } from "@dci/core";2import { pg, likePattern, projectLive } from "../lib/sql.js";3import { int, num, reqStr, str, type Row } from "../lib/rows.js";4import { hrefFor } from "../lib/resolve.js";5import { hasFilters, interpretQuery, type InterpretOptions } from "../search/interpret.js";6import { operatorCandidates } from "./operators.js";7import { listFacilities, searchCities } from "./facilities.js";8import { listProjects } from "./projects.js";9import { searchCountries } from "./countries.js";1011const MAX_HITS = 30;1213async function facilityHits(text: string, limit = 12): Promise<SearchHit[]> {14  const sql = pg();15  const rows = await sql<Row[]>`16    select f.id, f.slug, f.name, f.city, f.country_iso2, f.status, f.lat, f.lng, o.name as op_name,17      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 score18    from facilities f left join operators o on o.id = f.operator_id19    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)}))20    order by score desc, f.completeness desc limit ${limit}`;21  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) } }));22}2324async function operatorHits(text: string, limit = 5): Promise<SearchHit[]> {25  const sql = pg();26  const rows = await sql<Row[]>`27    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,28      (select count(*)::int from facilities f where f.operator_id = o.id and f.merged_into is null) as n29    from operators o30    where o.name % ${text} or o.name ilike ${likePattern(text)} or exists (select 1 from unnest(o.aliases) a where a ilike ${likePattern(text)})31    order by score desc, n desc limit ${limit}`;32  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) } }));33}3435async function metroHits(text: string, limit = 4): Promise<SearchHit[]> {36  const sql = pg();37  const rows = await sql<Row[]>`38    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,39      (select count(*)::int from facilities f where f.metro_id = m.id and f.merged_into is null) as n40    from metros m41    where m.name % ${text} or m.name ilike ${likePattern(text)} or exists (select 1 from unnest(m.aliases) a where a ilike ${likePattern(text)})42    order by score desc, n desc limit ${limit}`;43  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) } }));44}4546async function cloudRegionHits(text: string, limit = 4): Promise<SearchHit[]> {47  const sql = pg();48  const rows = await sql<Row[]>`49    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 score50    from cloud_regions r join operators pr on pr.id = r.provider_id51    where r.code ilike ${likePattern(text)} or r.name ilike ${likePattern(text)} or r.name % ${text}52    order by score desc limit ${limit}`;53  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)) }));54}5556async function projectHits(text: string, limit = 5): Promise<SearchHit[]> {57  const sql = pg();58  const rows = await sql<Row[]>`59    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 score60    from projects p left join operators o on o.id = p.operator_id61    where ${projectLive(sql)} and (p.name % ${text} or p.name ilike ${likePattern(text)})62    order by score desc, p.last_update desc limit ${limit}`;63  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)) }));64}6566async function ixpHits(text: string, limit = 3): Promise<SearchHit[]> {67  const sql = pg();68  const rows = await sql<Row[]>`69    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 score70    from ixps x where x.name ilike ${likePattern(text)} or x.name_long ilike ${likePattern(text)}71    order by score desc, x.network_count desc nulls last limit ${limit}`;72  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)) }));73}7475/** Metro candidates for query interpretation (trigram on name, substring on aliases). */76async function metroCandidates(q: string, limit = 5): Promise<NonNullable<InterpretOptions["metros"]>> {77  const sql = pg();78  const rows = await sql<Row[]>`79    select m.slug, m.name, m.aliases from metros m80    where position(lower(m.name) in lower(${q})) > 0 or (m.name % ${q} and similarity(m.name, ${q}) >= 0.5)81       or exists (select 1 from unnest(m.aliases) a where length(a) >= 3 and position(lower(a) in lower(${q})) > 0)82    order by similarity(m.name, ${q}) desc limit ${limit}`;83  return rows.map((r) => ({ slug: reqStr(r.slug), name: reqStr(r.name), aliases: Array.isArray(r.aliases) ? (r.aliases as string[]) : [] }));84}8586/** Facility ids behind a search response (hits + list), for the envelope `sources`. */87export function facilityIdsOf(res: SearchResponse): string[] {88  const ids = new Set<string>();89  for (const h of res.hits) if (h.type === "facility") ids.add(h.id);90  for (const f of res.facilities?.items ?? []) ids.add(f.id);91  return [...ids];92}9394export async function search(q: string): Promise<SearchResponse> {95  const query = q.replace(/\s+/g, " ").trim().slice(0, 200);96  if (!query) return { query: "", hits: [] };97  const [ops, metros] = await Promise.all([operatorCandidates(query, 5).catch(() => []), metroCandidates(query, 5).catch(() => [])]);98  const interpreted = interpretQuery(query, { operators: ops, metros });99  const text = interpreted.text ?? "";100  const hits: SearchHit[] = [];101  const tasks: Promise<SearchHit[]>[] = [];102  if (text) {103    tasks.push(facilityHits(text), operatorHits(text), metroHits(text), cloudRegionHits(text), projectHits(text), ixpHits(text));104    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 } }))));105    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 } }))));106  }107  // structured hits for the interpreted filters (country / operator)108  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 } }))));109  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 }); }110  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 }); }111  const results = await Promise.allSettled(tasks);112  for (const r of results) if (r.status === "fulfilled") hits.push(...r.value);113  // dedupe by type+id114  const seen = new Set<string>();115  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);116  const response: SearchResponse = { query, interpreted, hits: merged.map((h) => ({ ...h, score: Math.round(h.score * 1000) / 1000 })) };117  const yearFrom = interpreted.year != null && interpreted.yearOp !== "before" ? interpreted.year : undefined;118  const yearTo = interpreted.year != null && interpreted.yearOp !== "after" ? interpreted.year : undefined;119  if (interpreted.entity === "project") {120    // project intent: rank project rows instead of facilities (planned MW, status, country, AI, expected year)121    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 }));122    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));123    const seenP = new Set(response.hits.filter((h) => h.type === "project").map((h) => h.id));124    for (const p of filtered) {125      if (seenP.has(p.id)) continue;126      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 } });127    }128    response.hits = response.hits.sort((a, b) => b.score - a.score).slice(0, MAX_HITS);129    return response;130  }131  if (hasFilters(interpreted) || text) {132    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" });133    response.facilities = { total: fl.total, items: fl.items };134  }135  return response;136}137