This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

stigmergic / src / island-shared.ts
5.7 kB 181 lines
1/** 2 * Shared island utilities: canonical ID, component types, BFS, DB access. 3 */ 4 5import { resolve } from 'node:path'; 6import { createHash } from 'node:crypto'; 7import { Database } from 'bun:sqlite'; 8 9const CARRY_DIR = process.env.CARRY_DIR ?? '/home/nandi/.local/share/carry-vault'; 10const DB_PATH = process.env.ISLAND_DETECT_DB ?? resolve(CARRY_DIR, '.island-detect.db'); 11 12export interface Component { 13 nodes: Set<string>; 14 dids: Set<string>; 15 lexmin: string; 16 centroid: string; 17 islandId: string; 18} 19 20export interface EdgeRow { 21 source: string; 22 target: string; 23 relation: string | null; 24 did: string; 25 atUri: string | null; 26} 27 28// --- Canonical island ID: first 12 hex chars of SHA-256(centroid) --- 29 30export function islandId(centroid: string): string { 31 return createHash('sha256').update(centroid).digest('hex').slice(0, 12); 32} 33 34// --- Centroid: vertex with highest closeness centrality (smallest avg distance) --- 35 36export function computeCentroid(adj: Map<string, Set<string>>, nodes: Set<string>): string { 37 if (nodes.size <= 1) return [...nodes][0] ?? ''; 38 39 let bestNode = ''; 40 let bestAvg = Infinity; 41 42 for (const start of nodes) { 43 // BFS from start 44 const dist = new Map<string, number>(); 45 dist.set(start, 0); 46 const queue = [start]; 47 while (queue.length > 0) { 48 const current = queue.shift()!; 49 for (const neighbor of adj.get(current) ?? []) { 50 if (!dist.has(neighbor) && nodes.has(neighbor)) { 51 dist.set(neighbor, dist.get(current)! + 1); 52 queue.push(neighbor); 53 } 54 } 55 } 56 57 // Average distance to all reachable nodes 58 let totalDist = 0; 59 let reachable = 0; 60 for (const [node, d] of dist) { 61 if (nodes.has(node)) { 62 totalDist += d; 63 reachable++; 64 } 65 } 66 67 const avg = reachable > 0 ? totalDist / reachable : Infinity; 68 if (avg < bestAvg) { 69 bestAvg = avg; 70 bestNode = start; 71 } 72 } 73 74 return bestNode; 75} 76 77// --- SQLite --- 78 79export function openDb(): Database { 80 return new Database(DB_PATH, { readonly: true }); 81} 82 83// --- Connected components (BFS) --- 84 85export function findComponents(db: Database): Component[] { 86 const adj = new Map<string, Set<string>>(); 87 const edgeDids = new Map<string, Set<string>>(); 88 89 const rows = db.query('SELECT source, target, did FROM edges').all() as Array<{ source: string; target: string; did: string }>; 90 for (const row of rows) { 91 const s = row.source; 92 const t = row.target; 93 if (!adj.has(s)) adj.set(s, new Set()); 94 if (!adj.has(t)) adj.set(t, new Set()); 95 adj.get(s)!.add(t); 96 adj.get(t)!.add(s); 97 if (!edgeDids.has(s)) edgeDids.set(s, new Set()); 98 if (!edgeDids.has(t)) edgeDids.set(t, new Set()); 99 edgeDids.get(s)!.add(row.did); 100 edgeDids.get(t)!.add(row.did); 101 } 102 103 const visited = new Set<string>(); 104 const components: Component[] = []; 105 106 for (const node of adj.keys()) { 107 if (visited.has(node)) continue; 108 const compNodes = new Set<string>(); 109 const compDids = new Set<string>(); 110 const queue = [node]; 111 while (queue.length > 0) { 112 const current = queue.pop()!; 113 if (visited.has(current)) continue; 114 visited.add(current); 115 compNodes.add(current); 116 for (const did of edgeDids.get(current) ?? []) compDids.add(did); 117 for (const neighbor of adj.get(current) ?? []) { 118 if (!visited.has(neighbor)) queue.push(neighbor); 119 } 120 } 121 const lexmin = [...compNodes].sort()[0]; 122 const centroid = computeCentroid(adj, compNodes); 123 components.push({ nodes: compNodes, dids: compDids, lexmin, centroid, islandId: islandId(centroid) }); 124 } 125 126 return components.sort((a, b) => b.nodes.size - a.nodes.size); 127} 128 129/** Find a component by its island ID (centroid hash). */ 130export function findComponentById(db: Database, targetId: string): Component | undefined { 131 return findComponents(db).find((c) => c.islandId === targetId); 132} 133 134/** Fetch edges for a component from the DB. */ 135export function fetchComponentEdges(db: Database, component: Component): EdgeRow[] { 136 const nodePlaceholders = [...component.nodes].map(() => '?').join(','); 137 const nodeValues = [...component.nodes]; 138 139 // at_uri column may not exist yet — try with it, fall back without 140 let edges: EdgeRow[]; 141 try { 142 edges = db.query( 143 `SELECT source, target, relation, did, at_uri as atUri FROM edges WHERE source IN (${nodePlaceholders}) OR target IN (${nodePlaceholders})`, 144 ).all(...nodeValues, ...nodeValues) as EdgeRow[]; 145 } catch { 146 edges = db.query( 147 `SELECT source, target, relation, did, NULL as atUri FROM edges WHERE source IN (${nodePlaceholders}) OR target IN (${nodePlaceholders})`, 148 ).all(...nodeValues, ...nodeValues) as EdgeRow[]; 149 } 150 151 // Deduplicate (same source+target from multiple DIDs) 152 const seen = new Set<string>(); 153 const unique: EdgeRow[] = []; 154 for (const e of edges) { 155 const key = `${e.source}::${e.target}`; 156 if (!seen.has(key)) { 157 seen.add(key); 158 unique.push(e); 159 } 160 } 161 return unique; 162} 163 164/** Resolve handles for a set of DIDs. */ 165export function resolveDidHandles(db: Database, dids: Set<string>): Map<string, string | null> { 166 const handles = new Map<string, string | null>(); 167 if (dids.size === 0) return handles; 168 const placeholders = [...dids].map(() => '?').join(','); 169 const rows = db.query(`SELECT did, handle FROM dids WHERE did IN (${placeholders})`).all(...dids) as Array<{ did: string; handle: string | null }>; 170 for (const r of rows) handles.set(r.did, r.handle); 171 return handles; 172} 173 174/** Extract domain from a URL for display. */ 175export function domainFromUrl(url: string): string { 176 try { 177 return new URL(url).hostname.replace(/^www\./, ''); 178 } catch { 179 return url; 180 } 181}