This repository has no description
0

Configure Feed

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

stigmergic / src / island-discover.ts
13 kB 339 lines
1/** 2 * island:discover 3 * 4 * Find connected components in the global network.cosmik.connection graph. 5 * Uses SQLite to cache network data so we don't rebuild each time. 6 * 7 * Usage: 8 * bun island:discover # find all connected components 9 * bun island:discover --refresh # force re-fetch all DIDs 10 * bun island:discover <island-id> # show full details for an island 11 * bun island:discover --min-size 5 # only show components with >= 5 nodes 12 * bun island:discover --json # machine-readable output 13 */ 14 15import { resolve, dirname } from 'node:path'; 16import { fileURLToPath } from 'node:url'; 17import { createHash } from 'node:crypto'; 18import { Database } from 'bun:sqlite'; 19import { loadDotEnv } from './cli-utils.js'; 20import { islandId, domainFromUrl, computeCentroid, type Component } from './island-shared.js'; 21 22const __dirname = dirname(fileURLToPath(import.meta.url)); 23await loadDotEnv(resolve(__dirname, '..', '.env')); 24 25const RELAY = process.env.ATPROTO_RELAY ?? 'https://bsky.network'; 26const CARRY_DIR = process.env.CARRY_DIR ?? '/home/nandi/.local/share/carry-vault'; 27const DB_PATH = process.env.ISLAND_DETECT_DB ?? resolve(CARRY_DIR, '.island-detect.db'); 28const COLLECTION = 'network.cosmik.connection'; 29const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour 30 31// --- SQLite --- 32 33function openDb(): Database { 34 const db = new Database(DB_PATH, { create: true }); 35 db.exec('PRAGMA journal_mode = WAL'); 36 db.exec('PRAGMA synchronous = NORMAL'); 37 db.exec(` 38 CREATE TABLE IF NOT EXISTS edges ( 39 source TEXT NOT NULL, 40 target TEXT NOT NULL, 41 relation TEXT, 42 did TEXT NOT NULL, 43 fetched_at TEXT NOT NULL, 44 PRIMARY KEY (source, target, did) 45 ); 46 CREATE TABLE IF NOT EXISTS dids ( 47 did TEXT PRIMARY KEY, 48 handle TEXT, 49 pds TEXT, 50 edge_count INTEGER DEFAULT 0, 51 fetched_at TEXT 52 ); 53 CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source); 54 CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target); 55 `); 56 return db; 57} 58 59// --- Network discovery --- 60 61async function discoverDIDs(): Promise<string[]> { 62 const dids: string[] = []; 63 let cursor = ''; 64 while (true) { 65 const url = new URL(`${RELAY}/xrpc/com.atproto.sync.listReposByCollection`); 66 url.searchParams.set('collection', COLLECTION); 67 url.searchParams.set('limit', '1000'); 68 if (cursor) url.searchParams.set('cursor', cursor); 69 const res = await fetch(url.toString()); 70 if (!res.ok) break; 71 const data = await res.json() as { repos: Array<{ did: string }>; cursor?: string }; 72 for (const r of data.repos) dids.push(r.did); 73 cursor = data.cursor ?? ''; 74 if (!cursor) break; 75 await new Promise((r) => setTimeout(r, 200)); 76 } 77 return dids; 78} 79 80async function resolvePDS(did: string): Promise<string | null> { 81 try { 82 const res = await fetch(`https://plc.directory/${did}`); 83 if (!res.ok) return null; 84 const doc = await res.json() as { service?: Array<{ id: string; serviceEndpoint: string }> }; 85 return doc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint ?? null; 86 } catch { return null; } 87} 88 89async function resolveHandle(did: string): Promise<string | null> { 90 try { 91 const res = await fetch(`https://plc.directory/${did}`); 92 if (!res.ok) return null; 93 const doc = await res.json() as { alsoKnownAs?: string[] }; 94 return doc.alsoKnownAs?.find((h) => h.startsWith('at://'))?.replace('at://', '') ?? null; 95 } catch { return null; } 96} 97 98async function fetchConnectionRecords(pds: string, did: string): Promise<Array<{ source: string; target: string; relation?: string; atUri: string }>> { 99 const records: Array<{ source: string; target: string; relation?: string; atUri: string }> = []; 100 let cursor = ''; 101 while (true) { 102 const url = new URL(`${pds}/xrpc/com.atproto.repo.listRecords`); 103 url.searchParams.set('repo', did); 104 url.searchParams.set('collection', COLLECTION); 105 url.searchParams.set('limit', '100'); 106 if (cursor) url.searchParams.set('cursor', cursor); 107 const res = await fetch(url.toString()); 108 if (!res.ok) break; 109 const data = await res.json() as { 110 records: Array<{ uri: string; value: { source?: string; target?: string; connectionType?: string } }>; 111 cursor?: string; 112 }; 113 for (const r of data.records) { 114 if (r.value.source && r.value.target) { 115 records.push({ source: r.value.source, target: r.value.target, relation: r.value.connectionType, atUri: r.uri }); 116 } 117 } 118 cursor = data.cursor ?? ''; 119 if (!cursor) break; 120 await new Promise((r) => setTimeout(r, 100)); 121 } 122 return records; 123} 124 125// --- Cache operations --- 126 127function isCached(db: Database, did: string): boolean { 128 const row = db.query('SELECT fetched_at FROM dids WHERE did = ?').get(did) as { fetched_at: string } | null; 129 if (!row) return false; 130 return Date.now() - new Date(row.fetched_at).getTime() < CACHE_TTL_MS; 131} 132 133function upsertDid(db: Database, did: string, handle: string | null, pds: string | null, edgeCount: number): void { 134 const now = new Date().toISOString(); 135 db.query(` 136 INSERT INTO dids (did, handle, pds, edge_count, fetched_at) 137 VALUES (?, ?, ?, ?, ?) 138 ON CONFLICT(did) DO UPDATE SET handle=?, pds=?, edge_count=?, fetched_at=? 139 `).run(did, handle, pds, edgeCount, now, handle, pds, edgeCount, now); 140} 141 142function upsertEdges(db: Database, did: string, records: Array<{ source: string; target: string; relation?: string; atUri: string }>): void { 143 const now = new Date().toISOString(); 144 // Add at_uri column if missing 145 try { db.exec('ALTER TABLE edges ADD COLUMN at_uri TEXT'); } catch {} 146 const insert = db.query(` 147 INSERT INTO edges (source, target, relation, did, at_uri, fetched_at) 148 VALUES (?, ?, ?, ?, ?, ?) 149 ON CONFLICT(source, target, did) DO UPDATE SET relation=?, at_uri=?, fetched_at=? 150 `); 151 // Delete stale edges for this DID before inserting 152 db.query('DELETE FROM edges WHERE did = ?').run(did); 153 for (const r of records) { 154 insert.run(r.source, r.target, r.relation ?? null, did, r.atUri, now, r.relation ?? null, r.atUri, now); 155 } 156} 157 158// --- Connected components --- 159 160function findComponents(db: Database): Component[] { 161 // Build adjacency list from all edges 162 const adj = new Map<string, Set<string>>(); 163 const edgeDids = new Map<string, Set<string>>(); // node -> which DIDs have edges touching it 164 165 const rows = db.query('SELECT source, target, did FROM edges').all() as Array<{ source: string; target: string; did: string }>; 166 for (const row of rows) { 167 const s = row.source; 168 const t = row.target; 169 if (!adj.has(s)) adj.set(s, new Set()); 170 if (!adj.has(t)) adj.set(t, new Set()); 171 adj.get(s)!.add(t); 172 adj.get(t)!.add(s); 173 if (!edgeDids.has(s)) edgeDids.set(s, new Set()); 174 if (!edgeDids.has(t)) edgeDids.set(t, new Set()); 175 edgeDids.get(s)!.add(row.did); 176 edgeDids.get(t)!.add(row.did); 177 } 178 179 // BFS to find components 180 const visited = new Set<string>(); 181 const components: Component[] = []; 182 183 for (const node of adj.keys()) { 184 if (visited.has(node)) continue; 185 const compNodes = new Set<string>(); 186 const compDids = new Set<string>(); 187 const queue = [node]; 188 while (queue.length > 0) { 189 const current = queue.pop()!; 190 if (visited.has(current)) continue; 191 visited.add(current); 192 compNodes.add(current); 193 for (const did of edgeDids.get(current) ?? []) compDids.add(did); 194 for (const neighbor of adj.get(current) ?? []) { 195 if (!visited.has(neighbor)) queue.push(neighbor); 196 } 197 } 198 const lexmin = [...compNodes].sort()[0]; 199 const centroid = computeCentroid(adj, compNodes); 200 components.push({ nodes: compNodes, dids: compDids, lexmin, centroid, islandId: islandId(centroid) }); 201 } 202 203 return components.sort((a, b) => b.nodes.size - a.nodes.size); 204} 205 206// --- Main --- 207 208async function main(): Promise<void> { 209 const args = process.argv.slice(2); 210 const wantJson = args.includes('--json'); 211 const refresh = args.includes('--refresh'); 212 const targetId = args.find((a) => !a.startsWith('--')); 213 const minSizeArg = args.find((a) => a.startsWith('--min-size=')); 214 const minSize = minSizeArg ? Number(minSizeArg.split('=')[1]) : 3; 215 216 const db = openDb(); 217 218 console.error('Discovering network DIDs...'); 219 const networkDids = await discoverDIDs(); 220 console.error(`Found ${networkDids.length} DIDs`); 221 222 // Fetch edges for each DID (use cache unless --refresh) 223 let fetched = 0; 224 let cached = 0; 225 for (let i = 0; i < networkDids.length; i++) { 226 const did = networkDids[i]; 227 if (!refresh && isCached(db, did)) { 228 cached++; 229 continue; 230 } 231 console.error(`[${i + 1}/${networkDids.length}] Fetching ${did}...`); 232 const pds = await resolvePDS(did); 233 if (!pds) { console.error(' Could not resolve PDS'); continue; } 234 const handle = await resolveHandle(did); 235 const records = await fetchConnectionRecords(pds, did); 236 upsertDid(db, did, handle, pds, records.length); 237 upsertEdges(db, did, records); 238 fetched++; 239 } 240 console.error(`Fetched: ${fetched}, Cached: ${cached}`); 241 242 // Stats 243 const totalEdges = (db.query('SELECT COUNT(*) as c FROM edges').get() as { c: number }).c; 244 const totalDids = (db.query('SELECT COUNT(*) as c FROM dids').get() as { c: number }).c; 245 const uniqueUrls = new Set<string>(); 246 const urlRows = db.query('SELECT DISTINCT source FROM edges UNION SELECT DISTINCT target FROM edges').all() as Array<{ source: string }>; 247 for (const r of urlRows) uniqueUrls.add(r.source); 248 249 console.error(`Database: ${totalEdges} edges, ${totalDids} DIDs, ${uniqueUrls.size} unique URLs`); 250 251 // Find connected components 252 const components = findComponents(db); 253 const filtered = components.filter((c) => c.nodes.size >= minSize); 254 255 // Show a specific island by ID 256 if (targetId) { 257 const comp = components.find((c) => c.islandId === targetId); 258 if (!comp) { 259 console.error(`Island ${targetId} not found.`); 260 // Suggest similar IDs 261 const similar = filtered.filter((c) => c.islandId.startsWith(targetId.slice(0, 4))); 262 if (similar.length > 0) { 263 console.error(`Similar IDs:`); 264 for (const s of similar.slice(0, 5)) console.error(` ${s.islandId} ${s.nodes.size} nodes ${domainFromUrl(s.centroid ?? s.lexmin)}`); 265 } else { 266 console.error(`Run without args to list available islands.`); 267 } 268 process.exit(1); 269 } 270 const didPlaceholders = [...comp.dids].map(() => '?').join(','); 271 const didRows = db.query(`SELECT did, handle, edge_count FROM dids WHERE did IN (${didPlaceholders})`).all(...comp.dids) as Array<{ did: string; handle: string | null; edge_count: number }>; 272 if (wantJson) { 273 console.log(JSON.stringify({ 274 id: comp.islandId, 275 centroid: comp.centroid, 276 lexmin: comp.lexmin, 277 nodeCount: comp.nodes.size, 278 didCount: comp.dids.size, 279 nodes: [...comp.nodes].sort(), 280 dids: didRows, 281 }, null, 2)); 282 } else { 283 console.log(`Island ${comp.islandId}: ${comp.nodes.size} nodes, ${comp.dids.size} DIDs`); 284 console.log(`Centroid: ${comp.centroid}`); 285 console.log('\nNodes:'); 286 for (const url of [...comp.nodes].sort()) console.log(` ${url}`); 287 console.log('\nDIDs:'); 288 for (const d of didRows) console.log(` ${d.handle ?? d.did} (${d.edge_count} edges)`); 289 } 290 db.close(); 291 return; 292 } 293 294 if (wantJson) { 295 console.log(JSON.stringify({ 296 totalEdges, 297 totalDids, 298 uniqueUrls: uniqueUrls.size, 299 componentCount: filtered.length, 300 components: filtered.map((c) => ({ 301 id: c.islandId, 302 centroid: c.centroid, 303 lexmin: c.lexmin, 304 nodeCount: c.nodes.size, 305 didCount: c.dids.size, 306 sampleNodes: [...c.nodes].sort().slice(0, 10), 307 dids: [...c.dids], 308 })), 309 }, null, 2)); 310 db.close(); 311 return; 312 } 313 314 console.log(`\nIsland discover: ${totalDids} DIDs, ${totalEdges} edges, ${uniqueUrls.size} unique URLs`); 315 console.log(`Connected components (>= ${minSize} nodes): ${filtered.length}\n`); 316 317 for (const comp of filtered.slice(0, 30)) { 318 const domain = domainFromUrl(comp.centroid); 319 console.log(`${comp.islandId} ${comp.nodes.size} nodes, ${comp.dids.size} DIDs ${domain}`); 320 } 321 322 // Show which component we're in (if we can determine our DID) 323 const ourDid = process.env.BLUESKY_IDENTIFIER 324 ? (db.query('SELECT did FROM dids WHERE handle = ? OR did LIKE ?').get(process.env.BLUESKY_IDENTIFIER, `%${process.env.BLUESKY_IDENTIFIER}%`) as { did: string } | null) 325 : null; 326 if (ourDid) { 327 const ourComp = components.find((c) => c.dids.has(ourDid.did)); 328 if (ourComp) { 329 console.log(`\nOur island: ${ourComp.islandId} (${ourComp.nodes.size} nodes, shared with ${ourComp.dids.size - 1} other DIDs)`); 330 } 331 } 332 333 db.close(); 334} 335 336main().catch((err) => { 337 console.error('Fatal:', err instanceof Error ? err.message : String(err)); 338 process.exit(1); 339});