SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
6.3 KB · 100 lines typescript
Raw Blame History
1/**2 * sourceHistory: a merged, dated trail of where an entity's data came from — provenance observations,3 * detected events and page versions of the documents that reference the entity. Shared by facilities,4 * operators and projects.5 */6import type { EventDTO, ProvenanceDTO, SourceHistoryItem, SourceRef } from "@dci/core";7import { pg } from "./sql.js";8import { int, iso, reqStr, type Row } from "./rows.js";9import { asSourceKind, sourceRef } from "./dto.js";1011const ANNOUNCE_EVENTS = new Set(["project_announced", "phase_announced", "expansion_announced", "cloud_region_announced", "investment_announced", "power_agreement", "planning_filed"]);12const CHANGE_EVENTS = new Set(["status_changed", "capacity_changed", "planned_capacity_changed", "opening_date_changed", "operator_changed", "owner_changed", "project_status_changed", "planning_approved", "construction_started", "facility_opened", "acquisition", "closure", "cloud_region_launched", "cloud_region_updated"]);13const DETECT_EVENTS = new Set(["facility_discovered", "page_changed", "incident", "news"]);1415function fmtValue(v: unknown): string {16  if (v == null) return "—";17  if (typeof v === "object") { const s = JSON.stringify(v); return s.length > 80 ? s.slice(0, 77) + "…" : s; }18  const s = String(v);19  return s.length > 80 ? s.slice(0, 77) + "…" : s;20}2122export interface DocVersionRow { fetchedAt: string; url: string; sourceId: string; sourceName: string; sourceKind: string; significance: number; changes: number }2324/** Document versions for documents whose entity_refs contain (type,id). */25export async function documentVersionsFor(entityType: string, entityId: string, limit = 60): Promise<DocVersionRow[]> {26  const sql = pg();27  const refJson = JSON.stringify([{ type: entityType, id: entityId }]);28  const rows = await sql<Row[]>`29    select v.fetched_at, d.url, d.source_id, s.name as source_name, s.kind as source_kind, v.significance, jsonb_array_length(coalesce(v.detected_changes, '[]'::jsonb)) as changes30    from documents d31    join document_versions v on v.document_id = d.id32    left join sources s on s.id = d.source_id33    where d.entity_refs @> ${refJson}::jsonb34    order by v.fetched_at desc35    limit ${limit}`;36  return rows.map((r) => ({ fetchedAt: reqStr(iso(r.fetched_at)), url: reqStr(r.url), sourceId: reqStr(r.source_id), sourceName: reqStr(r.source_name, reqStr(r.source_id)), sourceKind: reqStr(r.source_kind, "secondary"), significance: int(r.significance), changes: int(r.changes) }));37}3839export function buildSourceHistory(provenance: ProvenanceDTO[], events: EventDTO[], versions: DocVersionRow[], max = 150): SourceHistoryItem[] {40  const items: SourceHistoryItem[] = [];41  const seen = new Set<string>();42  const push = (it: SourceHistoryItem) => {43    const k = `${it.date.slice(0, 10)}|${it.sourceId}|${it.kind}|${it.description}`;44    if (seen.has(k)) return;45    seen.add(k);46    items.push(it);47  };48  for (const p of provenance) {49    push({ date: p.firstObserved, sourceId: p.sourceId, sourceName: p.sourceName, sourceKind: p.sourceKind, url: p.url, kind: "observed", description: `${p.field} observed: ${fmtValue(p.value)}${p.isEstimate ? " (estimate)" : ""}` });50    if (p.lastObserved.slice(0, 10) !== p.firstObserved.slice(0, 10)) push({ date: p.lastObserved, sourceId: p.sourceId, sourceName: p.sourceName, sourceKind: p.sourceKind, url: p.url, kind: "verified", description: `${p.field} re-confirmed (${fmtValue(p.value)})` });51  }52  for (const e of events) {53    const kind: SourceHistoryItem["kind"] = ANNOUNCE_EVENTS.has(e.eventType) ? "announced" : CHANGE_EVENTS.has(e.eventType) ? "changed" : DETECT_EVENTS.has(e.eventType) ? "detected" : "updated";54    const desc = e.oldValue != null && e.newValue != null && kind === "changed" ? `${e.title} (${fmtValue(e.oldValue)} → ${fmtValue(e.newValue)})` : e.title;55    push({ date: e.detectedAt, sourceId: e.sourceId, sourceName: e.sourceName, sourceKind: e.sourceKind, url: e.url, kind, description: desc });56  }57  for (const v of versions) {58    push({ date: v.fetchedAt, sourceId: v.sourceId, sourceName: v.sourceName, sourceKind: asSourceKind(v.sourceKind), url: v.url, kind: "changed", description: v.changes > 0 ? `Source page changed — ${v.changes} field change${v.changes > 1 ? "s" : ""} detected (significance ${v.significance})` : `Source page changed (significance ${v.significance})` });59  }60  items.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));61  return items.slice(0, max);62}6364/** SourceRef[] for a set of source ids (envelope `sources`). */65export async function sourceRefsFor(ids: Iterable<string>): Promise<SourceRef[]> {66  const list = [...new Set([...ids].filter(Boolean))];67  if (!list.length) return [];68  const sql = pg();69  const rows = await sql<Row[]>`select id, name, domain, kind, url, license, attribution, redistribution, attribution_required from sources where id = any(${list}) order by name`;70  return rows.map(sourceRef);71}7273export function sourceIdsOf(...lists: Array<Array<{ sourceId: string }>>): Set<string> {74  const s = new Set<string>();75  for (const l of lists) for (const x of l) if (x.sourceId) s.add(x.sourceId);76  return s;77}7879/** Distinct sources behind a set of entities (current provenance rows), capped — for LIST envelopes. */80export async function sourcesForEntities(entityType: string, ids: string[], cap = 30): Promise<SourceRef[]> {81  const list = [...new Set(ids.filter(Boolean))];82  if (!list.length) return [];83  const sql = pg();84  const rows = await sql<Row[]>`85    select s.id, s.name, s.domain, s.kind, s.url, s.license, s.attribution, s.redistribution, s.attribution_required, count(*) as n86    from provenance p join sources s on s.id = p.source_id87    where p.entity_type = ${entityType} and p.entity_id = any(${list}) and p.is_current88    group by s.id order by n desc, s.name limit ${cap}`;89  return rows.map(sourceRef);90}9192/** Distinct sources of a set of source ids (events / map points), capped. */93export async function sourcesForIds(ids: Iterable<string>, cap = 30): Promise<SourceRef[]> {94  const list = [...new Set([...ids].filter(Boolean))].slice(0, 500);95  if (!list.length) return [];96  const sql = pg();97  const rows = await sql<Row[]>`select id, name, domain, kind, url, license, attribution, redistribution, attribution_required from sources where id = any(${list}) order by name limit ${cap}`;98  return rows.map(sourceRef);99}100