/** * Shared island utilities: canonical ID, component types, BFS, DB access. */ import { resolve } from 'node:path'; import { createHash } from 'node:crypto'; import { Database } from 'bun:sqlite'; const CARRY_DIR = process.env.CARRY_DIR ?? '/home/nandi/.local/share/carry-vault'; const DB_PATH = process.env.ISLAND_DETECT_DB ?? resolve(CARRY_DIR, '.island-detect.db'); export interface Component { nodes: Set; dids: Set; lexmin: string; centroid: string; islandId: string; } export interface EdgeRow { source: string; target: string; relation: string | null; did: string; atUri: string | null; } // --- Canonical island ID: first 12 hex chars of SHA-256(centroid) --- export function islandId(centroid: string): string { return createHash('sha256').update(centroid).digest('hex').slice(0, 12); } // --- Centroid: vertex with highest closeness centrality (smallest avg distance) --- export function computeCentroid(adj: Map>, nodes: Set): string { if (nodes.size <= 1) return [...nodes][0] ?? ''; let bestNode = ''; let bestAvg = Infinity; for (const start of nodes) { // BFS from start const dist = new Map(); dist.set(start, 0); const queue = [start]; while (queue.length > 0) { const current = queue.shift()!; for (const neighbor of adj.get(current) ?? []) { if (!dist.has(neighbor) && nodes.has(neighbor)) { dist.set(neighbor, dist.get(current)! + 1); queue.push(neighbor); } } } // Average distance to all reachable nodes let totalDist = 0; let reachable = 0; for (const [node, d] of dist) { if (nodes.has(node)) { totalDist += d; reachable++; } } const avg = reachable > 0 ? totalDist / reachable : Infinity; if (avg < bestAvg) { bestAvg = avg; bestNode = start; } } return bestNode; } // --- SQLite --- export function openDb(): Database { return new Database(DB_PATH, { readonly: true }); } // --- Connected components (BFS) --- export function findComponents(db: Database): Component[] { const adj = new Map>(); const edgeDids = new Map>(); const rows = db.query('SELECT source, target, did FROM edges').all() as Array<{ source: string; target: string; did: string }>; for (const row of rows) { const s = row.source; const t = row.target; if (!adj.has(s)) adj.set(s, new Set()); if (!adj.has(t)) adj.set(t, new Set()); adj.get(s)!.add(t); adj.get(t)!.add(s); if (!edgeDids.has(s)) edgeDids.set(s, new Set()); if (!edgeDids.has(t)) edgeDids.set(t, new Set()); edgeDids.get(s)!.add(row.did); edgeDids.get(t)!.add(row.did); } const visited = new Set(); const components: Component[] = []; for (const node of adj.keys()) { if (visited.has(node)) continue; const compNodes = new Set(); const compDids = new Set(); const queue = [node]; while (queue.length > 0) { const current = queue.pop()!; if (visited.has(current)) continue; visited.add(current); compNodes.add(current); for (const did of edgeDids.get(current) ?? []) compDids.add(did); for (const neighbor of adj.get(current) ?? []) { if (!visited.has(neighbor)) queue.push(neighbor); } } const lexmin = [...compNodes].sort()[0]; const centroid = computeCentroid(adj, compNodes); components.push({ nodes: compNodes, dids: compDids, lexmin, centroid, islandId: islandId(centroid) }); } return components.sort((a, b) => b.nodes.size - a.nodes.size); } /** Find a component by its island ID (centroid hash). */ export function findComponentById(db: Database, targetId: string): Component | undefined { return findComponents(db).find((c) => c.islandId === targetId); } /** Fetch edges for a component from the DB. */ export function fetchComponentEdges(db: Database, component: Component): EdgeRow[] { const nodePlaceholders = [...component.nodes].map(() => '?').join(','); const nodeValues = [...component.nodes]; // at_uri column may not exist yet — try with it, fall back without let edges: EdgeRow[]; try { edges = db.query( `SELECT source, target, relation, did, at_uri as atUri FROM edges WHERE source IN (${nodePlaceholders}) OR target IN (${nodePlaceholders})`, ).all(...nodeValues, ...nodeValues) as EdgeRow[]; } catch { edges = db.query( `SELECT source, target, relation, did, NULL as atUri FROM edges WHERE source IN (${nodePlaceholders}) OR target IN (${nodePlaceholders})`, ).all(...nodeValues, ...nodeValues) as EdgeRow[]; } // Deduplicate (same source+target from multiple DIDs) const seen = new Set(); const unique: EdgeRow[] = []; for (const e of edges) { const key = `${e.source}::${e.target}`; if (!seen.has(key)) { seen.add(key); unique.push(e); } } return unique; } /** Resolve handles for a set of DIDs. */ export function resolveDidHandles(db: Database, dids: Set): Map { const handles = new Map(); if (dids.size === 0) return handles; const placeholders = [...dids].map(() => '?').join(','); const rows = db.query(`SELECT did, handle FROM dids WHERE did IN (${placeholders})`).all(...dids) as Array<{ did: string; handle: string | null }>; for (const r of rows) handles.set(r.did, r.handle); return handles; } /** Extract domain from a URL for display. */ export function domainFromUrl(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return url; } }