/** * Facility merge: `dup` is folded into `into`. Aliases, provenance, tenants, IXPs, entity keys, projects and * events move to the survivor; the duplicate row stays with merged_into set (public endpoints hide it and * detail lookups follow the pointer). */ import { pg } from "../../lib/sql.js"; import { HttpError } from "../../lib/http.js"; import type { Row } from "../../lib/rows.js"; export interface MergeResult { into: string; merged: string; moved: Record } export async function mergeFacility(dupId: string, intoId: string, decidedBy = "admin"): Promise { if (dupId === intoId) throw new Error("cannot merge a facility into itself"); const sql = pg(); return sql.begin(async (tx) => { const both = await tx`select id, name, merged_into from facilities where id in (${dupId}, ${intoId})`; const dup = both.find((r) => r.id === dupId); const into = both.find((r) => r.id === intoId); if (!dup) throw new Error(`facility ${dupId} not found`); if (!into) throw new Error(`facility ${intoId} not found`); if (into.merged_into) throw new Error(`target ${intoId} is itself merged into ${String(into.merged_into)}`); const moved: Record = {}; const count = (r: { count: number }) => r.count; // aliases (+ the duplicate's own name) moved.aliases = count(await tx`insert into facility_aliases (facility_id, alias, normalized, source_id) select ${intoId}, alias, normalized, source_id from facility_aliases where facility_id = ${dupId} on conflict do nothing`); await tx`insert into facility_aliases (facility_id, alias, normalized, source_id) select ${intoId}, name, normalized_name, null from facilities where id = ${dupId} on conflict do nothing`; await tx`delete from facility_aliases where facility_id = ${dupId}`; // provenance: move rows that do not collide with the survivor's unique key, drop the rest moved.provenance = count(await tx`update provenance p set entity_id = ${intoId} where p.entity_type = 'facility' and p.entity_id = ${dupId} and not exists (select 1 from provenance q where q.entity_type = 'facility' and q.entity_id = ${intoId} and q.field = p.field and q.source_id = p.source_id and q.url = p.url)`); await tx`delete from provenance where entity_type = 'facility' and entity_id = ${dupId}`; moved.tenants = count(await tx`insert into facility_tenants (facility_id, operator_id, role, asn, source_id) select ${intoId}, operator_id, role, asn, source_id from facility_tenants where facility_id = ${dupId} on conflict do nothing`); await tx`delete from facility_tenants where facility_id = ${dupId}`; moved.ixps = count(await tx`insert into facility_ixps (facility_id, ixp_id, source_id) select ${intoId}, ixp_id, source_id from facility_ixps where facility_id = ${dupId} on conflict do nothing`); await tx`delete from facility_ixps where facility_id = ${dupId}`; moved.entityKeys = count(await tx`update entity_keys set entity_id = ${intoId} where entity_type = 'facility' and entity_id = ${dupId}`); moved.projects = count(await tx`update projects set facility_id = ${intoId} where facility_id = ${dupId}`); moved.events = count(await tx`update events set entity_id = ${intoId} where entity_type = 'facility' and entity_id = ${dupId}`); moved.documents = count(await tx`update documents set entity_refs = (select coalesce(jsonb_agg(distinct case when e->>'type' = 'facility' and e->>'id' = ${dupId} then jsonb_build_object('type', 'facility', 'id', ${intoId}::text) else e end), '[]'::jsonb) from jsonb_array_elements(entity_refs) e), updated_at = now() where entity_refs @> ${JSON.stringify([{ type: "facility", id: dupId }])}::jsonb`); await tx`update facilities set merged_into = ${intoId}, updated_at = now() where id = ${dupId}`; await tx`update facilities set source_count = (select count(distinct source_id) from provenance where entity_type = 'facility' and entity_id = ${intoId} and is_current), updated_at = now() where id = ${intoId}`; await tx`insert into events (id, entity_type, entity_id, event_type, detected_at, old_value, new_value, source_id, url, title, summary, significance, confidence, review_status, fingerprint) select ${"evt_" + Math.random().toString(36).slice(2, 14)}, 'facility', ${intoId}, 'facility_updated', now(), ${JSON.stringify({ mergedFacility: dupId, name: dup.name })}::jsonb, ${JSON.stringify({ into: intoId })}::jsonb, 'src_manual', '', ${"Duplicate merged: " + String(dup.name)}, ${"Merged by " + decidedBy}, 20, 'high', 'approved', ${"merge:" + dupId + ":" + intoId} where exists (select 1 from sources where id = 'src_manual') on conflict do nothing`; return { into: intoId, merged: dupId, moved }; }); } export interface ParentResult { id: string; parentId: string | null; recordScope: string; parentRecordScope: string | null; changed: boolean } /** * Containment link: `childId` becomes a building of `parentId` (null detaches). Guards: parent exists, is not merged, * is not the child, and the parent's own chain never leads back to the child (no cycles, checked 5 levels up). * record_scope: child → building (or facility when detached), parent → campus (stays campus while it has children). */ export async function setFacilityParent(childIdOrSlug: string, parentIdOrSlug: string | null, decidedBy = "admin"): Promise { const sql = pg(); const child = (await sql`select id, slug, name, parent_facility_id, record_scope, merged_into from facilities where id = ${childIdOrSlug} or slug = ${childIdOrSlug} order by (id = ${childIdOrSlug}) desc limit 1`)[0]; if (!child) throw new HttpError(404, "facility not found"); const childId = String(child.id); let parentId: string | null = null; if (parentIdOrSlug != null) { const parent = (await sql`select id, merged_into from facilities where id = ${parentIdOrSlug} or slug = ${parentIdOrSlug} order by (id = ${parentIdOrSlug}) desc limit 1`)[0]; if (!parent) throw new HttpError(400, "parent facility not found"); if (parent.merged_into) throw new HttpError(400, `parent is merged into ${String(parent.merged_into)}`); parentId = String(parent.id); if (parentId === childId) throw new HttpError(400, "a facility cannot be its own parent"); // cycle check: walk up from the parent let cur: string | null = parentId; for (let i = 0; i < 5 && cur; i++) { const up: Row | undefined = (await sql`select parent_facility_id from facilities where id = ${cur}`)[0]; cur = up?.parent_facility_id == null ? null : String(up.parent_facility_id); if (cur === childId) throw new HttpError(400, "containment cycle: the parent is already contained by this facility"); } } const previous = child.parent_facility_id == null ? null : String(child.parent_facility_id); const scope = parentId ? "building" : "facility"; if (previous === parentId && String(child.record_scope) === scope) return { id: childId, parentId, recordScope: scope, parentRecordScope: null, changed: false }; await ensureManualSource(); const now = new Date().toISOString(); const url = `https://www.datacenterindex.io/admin/facilities/${childId}`; let parentScope: string | null = null; await sql.begin(async (tx) => { await tx`update facilities set parent_facility_id = ${parentId}, record_scope = ${scope}, updated_at = now() where id = ${childId}`; for (const [field, value] of [["parentFacilityId", parentId], ["recordScope", scope]] as Array<[string, unknown]>) { await tx`insert into provenance (id, entity_type, entity_id, field, value, source_id, connector_id, document_id, url, first_observed, last_observed, retrieved_at, confidence, is_estimate, method, extractor_version, is_current, is_winner, note) values (${"prov_" + Math.random().toString(36).slice(2, 14)}, 'facility', ${childId}, ${field}, ${JSON.stringify(value)}::jsonb, 'src_manual', 'manual', null, ${url}, ${now}, ${now}, ${now}, 'high', false, 'manual', 'admin', true, true, ${"containment set by " + decidedBy}) on conflict (entity_type, entity_id, field, source_id, url) do update set value = excluded.value, last_observed = excluded.last_observed, retrieved_at = excluded.retrieved_at, is_current = true, is_winner = true, note = excluded.note`; } if (parentId) { await tx`update facilities set record_scope = 'campus', updated_at = now() where id = ${parentId} and record_scope <> 'campus'`; parentScope = "campus"; } // a former parent stays a campus only while it still has building rows if (previous && previous !== parentId) { const r = await tx`update facilities set record_scope = 'facility', updated_at = now() where id = ${previous} and record_scope = 'campus' and not exists (select 1 from facilities c where c.parent_facility_id = ${previous} and c.merged_into is null)`; if (r.count && !parentId) parentScope = "facility"; } }); return { id: childId, parentId, recordScope: scope, parentRecordScope: parentScope, changed: true }; } /** Ensure the manual-curation source exists (kind registry, "Manual curation"). */ export async function ensureManualSource(): Promise { const sql = pg(); await sql`insert into sources (id, connector_id, name, domain, kind, priority, url, license, attribution, notes) values ('src_manual', 'manual', 'Manual curation', 'datacenterindex.io', 'registry', 1, 'https://www.datacenterindex.io', 'internal', 'DataCenterIndex editorial team', 'Values entered or corrected by an administrator') on conflict (id) do nothing`; }