This repository has no description
0

Configure Feed

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

stigmergic / src / worker.ts
14 kB 452 lines
1import { Hono } from "hono"; 2import { cors } from "hono/cors"; 3import { createWorker } from "@atmo-dev/contrail/worker"; 4import { Contrail } from "@atmo-dev/contrail"; 5import { config } from "./contrail.config.js"; 6import { lexicons } from "../lexicons/generated/index.js"; 7import { LANDING_PAGE } from "./landing-page.js"; 8 9// ─── Env ──────────────────────────────────────────────────────────── 10 11interface Env { 12 DB: D1Database; 13 VAULT_BUCKET: R2Bucket; 14 CARRY_BUCKET: R2Bucket; 15} 16 17// ─── Contrail setup ───────────────────────────────────────────────── 18 19const contrailWorker = createWorker(config, { lexicons }); 20const contrail = new Contrail(config); 21let contrailReady = false; 22 23async function ensureContrailReady(db: D1Database): Promise<void> { 24 if (contrailReady) return; 25 await contrail.init(db); 26 contrailReady = true; 27} 28 29// ─── Island detection (connected components) ──────────────────────── 30// 31// Load all connections, build an undirected graph, find connected 32// components via BFS. Each component is an "island" — a cluster of 33// linked URLs that form a constellation in the knowledge graph. 34 35interface Island { 36 id: string; 37 vertices: string[]; 38 edges: Array<{ 39 uri: string; 40 did: string; 41 source: string; 42 target: string; 43 connectionType: string; 44 note: string; 45 handle?: string | null; 46 }>; 47} 48 49async function detectIslands(db: D1Database): Promise<Island[]> { 50 await ensureContrailReady(db); 51 52 // Load all connections 53 const rows = await db 54 .prepare( 55 `SELECT r.record, r.did, r.rkey, i.handle 56 FROM records_connection r 57 LEFT JOIN identities i ON r.did = i.did 58 ORDER BY r.time_us DESC`, 59 ) 60 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 61 62 // Build adjacency list 63 const adj = new Map<string, Set<string>>(); 64 const edges: Island["edges"] = []; 65 66 for (const row of rows.results || []) { 67 try { 68 const value = JSON.parse(row.record); 69 const source = value.source as string; 70 const target = value.target as string; 71 if (!source || !target) continue; 72 73 if (!adj.has(source)) adj.set(source, new Set()); 74 if (!adj.has(target)) adj.set(target, new Set()); 75 adj.get(source)!.add(target); 76 adj.get(target)!.add(source); 77 78 edges.push({ 79 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 80 did: row.did, 81 source, 82 target, 83 connectionType: value.connectionType || "relates", 84 note: value.note || "", 85 handle: row.handle, 86 }); 87 } catch {} 88 } 89 90 // BFS to find connected components 91 const visited = new Set<string>(); 92 const islands: Island[] = []; 93 94 for (const node of adj.keys()) { 95 if (visited.has(node)) continue; 96 97 const component: string[] = []; 98 const queue = [node]; 99 while (queue.length > 0) { 100 const current = queue.shift()!; 101 if (visited.has(current)) continue; 102 visited.add(current); 103 component.push(current); 104 105 for (const neighbor of adj.get(current) || []) { 106 if (!visited.has(neighbor)) queue.push(neighbor); 107 } 108 } 109 110 // Only include edges where both endpoints are in this component 111 const componentSet = new Set(component); 112 const componentEdges = edges.filter( 113 e => componentSet.has(e.source) && componentSet.has(e.target), 114 ); 115 116 islands.push({ 117 id: component.sort().join("|").slice(0, 64), // stable ID from sorted vertices 118 vertices: component, 119 edges: componentEdges, 120 }); 121 } 122 123 // Sort by size descending 124 islands.sort((a, b) => b.vertices.length - a.vertices.length); 125 return islands; 126} 127 128// ─── Resolve vertex metadata (batched) ────────────────────────────── 129// 130// Bulk queries instead of N+1 per vertex. Loads all cards and citations 131// matching the given URIs in a few chunked queries. 132 133async function resolveVertexMeta( 134 db: D1Database, 135 uris: string[], 136): Promise<Map<string, { title: string; description: string; type: string }>> { 137 const meta = new Map<string, { title: string; description: string; type: string }>(); 138 for (const uri of uris) { 139 meta.set(uri, { title: uri, description: "", type: "unknown" }); 140 } 141 142 const httpUris = uris.filter(u => u.startsWith("http")); 143 if (httpUris.length === 0) return meta; 144 145 // Chunk into batches of 50 for IN clauses (D1 limit) 146 const CHUNK = 50; 147 148 // Batch card lookup 149 for (let i = 0; i < httpUris.length; i += CHUNK) { 150 const chunk = httpUris.slice(i, i + CHUNK); 151 const placeholders = chunk.map(() => "?").join(","); 152 const rows = await db 153 .prepare( 154 `SELECT record FROM records_card 155 WHERE json_extract(record, '$.content.url') IN (${placeholders})`, 156 ) 157 .bind(...chunk) 158 .all<{ record: string }>(); 159 160 for (const row of rows.results || []) { 161 try { 162 const card = JSON.parse(row.record); 163 const url = card?.content?.url; 164 if (!url || !meta.has(url)) continue; 165 const m = card.content.metadata; 166 if (m?.title || m?.description) { 167 meta.set(url, { 168 title: m.title || url, 169 description: m.description || "", 170 type: card.type === "URL" ? "url" : "note", 171 }); 172 } 173 } catch {} 174 } 175 } 176 177 // Batch citation lookup (overrides card data if found) 178 for (let i = 0; i < httpUris.length; i += CHUNK) { 179 const chunk = httpUris.slice(i, i + CHUNK); 180 const placeholders = chunk.map(() => "?").join(","); 181 const rows = await db 182 .prepare( 183 `SELECT record FROM records_citation 184 WHERE json_extract(record, '$.url') IN (${placeholders})`, 185 ) 186 .bind(...chunk) 187 .all<{ record: string }>(); 188 189 for (const row of rows.results || []) { 190 try { 191 const citation = JSON.parse(row.record); 192 const url = citation.url; 193 if (!url || !meta.has(url)) continue; 194 if (citation.title) { 195 const existing = meta.get(url)!; 196 meta.set(url, { 197 title: citation.title, 198 description: citation.takeaway || existing.description, 199 type: "citation", 200 }); 201 } 202 } catch {} 203 } 204 } 205 206 return meta; 207} 208 209// ─── Island cache ────────────────────────────────────────────────── 210// 211// The scheduled handler computes islands and writes them to the 212// `island_cache` table. The read path just queries this table 213// instead of recomputing on every request. 214 215async function rebuildIslandCache(db: D1Database): Promise<void> { 216 await ensureContrailReady(db); 217 const islands = await detectIslands(db); 218 219 // Enrich with vertex metadata (batched) 220 const enriched = []; 221 for (const island of islands.slice(0, 50)) { 222 const meta = await resolveVertexMeta(db, island.vertices); 223 enriched.push({ 224 ...island, 225 vertexMeta: Object.fromEntries(meta), 226 }); 227 } 228 229 const now = Date.now() * 1000; 230 231 // Clear old cache and write new 232 await db.prepare("DELETE FROM island_cache").run(); 233 234 const stmts = enriched.map(island => 235 db 236 .prepare( 237 `INSERT INTO island_cache (id, vertex_count, edge_count, data, updated_at) 238 VALUES (?, ?, ?, ?, ?)`, 239 ) 240 .bind( 241 island.id, 242 island.vertices.length, 243 island.edges.length, 244 JSON.stringify(island), 245 now, 246 ), 247 ); 248 249 // D1 supports batched statements 250 if (stmts.length > 0) { 251 await db.batch(stmts); 252 } 253} 254 255async function readIslandCache(db: D1Database): Promise<any[]> { 256 const rows = await db 257 .prepare( 258 `SELECT data, summary, strata FROM island_cache ORDER BY vertex_count DESC LIMIT 50`, 259 ) 260 .all<{ data: string; summary: string | null; strata: string | null }>(); 261 262 return (rows.results || []).map(r => { 263 const island = JSON.parse(r.data); 264 island.summary = r.summary || null; 265 island.strata = r.strata ? JSON.parse(r.strata) : null; 266 return island; 267 }); 268} 269 270// ─── Hono app ─────────────────────────────────────────────────────── 271 272let app: Hono<{ Bindings: Env }> | null = null; 273 274function buildApp(env: Env): Hono<{ Bindings: Env }> { 275 const app = new Hono<{ Bindings: Env }>(); 276 const db = env.DB; 277 278 app.use("*", cors()); 279 280 // Landing page — islands feed from cache 281 app.get("/", async (c) => { 282 let islandsJson = "[]"; 283 try { 284 const islands = await readIslandCache(db); 285 islandsJson = JSON.stringify(islands); 286 } catch (e) { 287 console.error("Failed to load islands:", e); 288 } 289 290 const page = LANDING_PAGE.replace( 291 "</head>", 292 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 293 ); 294 return c.html(page); 295 }); 296 297 // ── Islands API ────────────────────────────────────────────────── 298 299 app.get("/api/strata/islands", async (c) => { 300 const islands = await readIslandCache(db); 301 return c.json({ islands }); 302 }); 303 304 // Update an island's summary 305 app.put("/api/strata/islands/:id/summary", async (c) => { 306 const id = decodeURIComponent(c.req.param("id")); 307 const body = await c.req.json<{ summary?: string }>().catch(() => ({ summary: undefined })); 308 if (!body.summary) { 309 return c.json({ error: "MissingSummary" }, 400); 310 } 311 await db 312 .prepare("UPDATE island_cache SET summary = ? WHERE id = ?") 313 .bind(body.summary, id) 314 .run(); 315 return c.json({ ok: true }); 316 }); 317 318 // Upload strata analysis for an island 319 app.put("/api/strata/islands/:id/analysis", async (c) => { 320 const id = decodeURIComponent(c.req.param("id")); 321 const body = await c.req.json().catch(() => ({})); 322 if (!body || !body.prose) { 323 return c.json({ error: "MissingRequiredField", message: "prose is required" }, 400); 324 } 325 const strataJson = JSON.stringify(body); 326 await db 327 .prepare("UPDATE island_cache SET strata = ? WHERE id = ?") 328 .bind(strataJson, id) 329 .run(); 330 return c.json({ ok: true }); 331 }); 332 333 // ── R2 sync endpoints ─────────────────────────────────────────── 334 335 app.put("/api/sync/vault/:did/*", async (c) => { 336 const did = c.req.param("did"); 337 const path = c.req.param("path"); 338 if (!did || !path) { 339 return c.json({ error: "Missing path" }, 400); 340 } 341 342 const key = `${did}/${path}`; 343 const body = await c.req.raw.arrayBuffer(); 344 await env.VAULT_BUCKET.put(key, body, { 345 httpMetadata: { contentType: c.req.header("content-type") || "text/markdown" }, 346 }); 347 348 return c.json({ ok: true, key }); 349 }); 350 351 app.put("/api/sync/carry/:did/*", async (c) => { 352 const did = c.req.param("did"); 353 const path = c.req.param("path"); 354 if (!did || !path) { 355 return c.json({ error: "Missing path" }, 400); 356 } 357 358 const key = `${did}/${path}`; 359 const body = await c.req.raw.arrayBuffer(); 360 await env.CARRY_BUCKET.put(key, body, { 361 httpMetadata: { contentType: c.req.header("content-type") || "application/json" }, 362 }); 363 364 return c.json({ ok: true, key }); 365 }); 366 367 // ── Graph neighborhood query ──────────────────────────────────── 368 369 app.get("/xrpc/org.latha.strata.connection.getGraph", async (c) => { 370 const uri = c.req.query("uri"); 371 if (!uri) { 372 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 373 } 374 375 const depth = Math.min(parseInt(c.req.query("depth") || "2"), 3); 376 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 377 const types = c.req.query("types")?.split(",").map((t) => t.trim()); 378 379 // Reuse island detection for the subgraph 380 const islands = await detectIslands(db); 381 const island = islands.find(i => i.vertices.includes(uri)); 382 383 if (!island) { 384 return c.json({ connections: [], resources: [], depth, uri }); 385 } 386 387 // Convert to graph format 388 const resources = island.vertices.map(v => ({ 389 uri: v, 390 title: v, 391 type: "unknown", 392 })); 393 394 return c.json({ 395 connections: island.edges, 396 resources, 397 depth, 398 uri, 399 }); 400 }); 401 402 // ── OAuth client metadata ────────────────────────────────────── 403 404 app.get("/oauth-client-metadata.json", (c) => { 405 const host = new URL(c.req.url).host; 406 return c.json({ 407 client_id: `https://${host}/oauth-client-metadata.json`, 408 client_name: "Stigmergic", 409 client_uri: `https://${host}`, 410 redirect_uris: [`https://${host}/`], 411 scope: "atproto transition:generic", 412 grant_types: ["authorization_code", "refresh_token"], 413 response_types: ["code"], 414 token_endpoint_auth_method: "none", 415 application_type: "web", 416 dpop_bound_access_tokens: true, 417 }); 418 }); 419 420 // ── All other routes pass through to contrail ────────────────── 421 422 app.all("*", async (c) => { 423 const response = await contrailWorker.fetch( 424 c.req.raw, 425 c.env as unknown as Record<string, unknown>, 426 ); 427 return response; 428 }); 429 430 return app; 431} 432 433// ─── Export ───────────────────────────────────────────────────────── 434 435export default { 436 fetch(request: Request, env: Env): Response | Promise<Response> { 437 app ??= buildApp(env); 438 return app.fetch(request, env); 439 }, 440 async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { 441 // Run Contrail indexing first 442 await contrailWorker.scheduled( 443 event, 444 env as unknown as Record<string, unknown>, 445 ctx, 446 ); 447 448 // Rebuild island cache in the background after indexing 449 ctx.waitUntil(rebuildIslandCache(env.DB)); 450 }, 451}; 452