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