This repository has no description
0

Configure Feed

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

stigmergic / src / worker.ts
31 kB 901 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"; 8import { StrataContainer } from "./container.js"; 9 10// ─── Env ──────────────────────────────────────────────────────────── 11 12interface Env { 13 DB: D1Database; 14 VAULT_BUCKET: R2Bucket; 15 CARRY_BUCKET: R2Bucket; 16 STRATA_CONTAINER: DurableObjectNamespace; 17} 18 19// ─── Contrail setup ───────────────────────────────────────────────── 20 21const contrailWorker = createWorker(config, { lexicons }); 22const contrail = new Contrail(config); 23let contrailReady = false; 24 25async function ensureContrailReady(db: D1Database): Promise<void> { 26 if (contrailReady) return; 27 await contrail.init(db); 28 contrailReady = true; 29} 30 31// ─── Island detection (connected components) ──────────────────────── 32// 33// Load all connections, build an undirected graph, find connected 34// components via BFS. Each component is an "island" — a cluster of 35// linked URLs that form a constellation in the knowledge graph. 36 37interface Island { 38 id: string; 39 vertices: string[]; 40 edges: Array<{ 41 uri: string; 42 did: string; 43 source: string; 44 target: string; 45 connectionType: string; 46 note: string; 47 handle?: string | null; 48 }>; 49} 50 51async function detectIslands(db: D1Database): Promise<Island[]> { 52 await ensureContrailReady(db); 53 54 // Load all connections 55 const rows = await db 56 .prepare( 57 `SELECT r.record, r.did, r.rkey, i.handle 58 FROM records_connection r 59 LEFT JOIN identities i ON r.did = i.did 60 ORDER BY r.time_us DESC`, 61 ) 62 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 63 64 // Build adjacency list 65 const adj = new Map<string, Set<string>>(); 66 const edges: Island["edges"] = []; 67 68 for (const row of rows.results || []) { 69 try { 70 const value = JSON.parse(row.record); 71 const source = value.source as string; 72 const target = value.target as string; 73 if (!source || !target) continue; 74 75 if (!adj.has(source)) adj.set(source, new Set()); 76 if (!adj.has(target)) adj.set(target, new Set()); 77 adj.get(source)!.add(target); 78 adj.get(target)!.add(source); 79 80 edges.push({ 81 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 82 did: row.did, 83 source, 84 target, 85 connectionType: value.connectionType || "relates", 86 note: value.note || "", 87 handle: row.handle, 88 }); 89 } catch {} 90 } 91 92 // BFS to find connected components 93 const visited = new Set<string>(); 94 const islands: Island[] = []; 95 96 for (const node of adj.keys()) { 97 if (visited.has(node)) continue; 98 99 const component: string[] = []; 100 const queue = [node]; 101 while (queue.length > 0) { 102 const current = queue.shift()!; 103 if (visited.has(current)) continue; 104 visited.add(current); 105 component.push(current); 106 107 for (const neighbor of adj.get(current) || []) { 108 if (!visited.has(neighbor)) queue.push(neighbor); 109 } 110 } 111 112 // Only include edges where both endpoints are in this component 113 const componentSet = new Set(component); 114 const componentEdges = edges.filter( 115 e => componentSet.has(e.source) && componentSet.has(e.target), 116 ); 117 118 // Stable ID: truncated SHA-256 of the lexicographic minimum vertex 119 const lexmin = component.sort()[0]; 120 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 121 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 122 123 islands.push({ 124 id, 125 vertices: component, 126 edges: componentEdges, 127 }); 128 } 129 130 // Sort by size descending 131 islands.sort((a, b) => b.vertices.length - a.vertices.length); 132 return islands; 133} 134 135// ─── Resolve vertex metadata (batched) ────────────────────────────── 136// 137// Bulk queries instead of N+1 per vertex. Loads all cards and citations 138// matching the given URIs in a few chunked queries. 139 140async function resolveVertexMeta( 141 db: D1Database, 142 uris: string[], 143): Promise<Map<string, { title: string; description: string; type: string }>> { 144 const meta = new Map<string, { title: string; description: string; type: string }>(); 145 for (const uri of uris) { 146 meta.set(uri, { title: uri, description: "", type: "unknown" }); 147 } 148 149 const httpUris = uris.filter(u => u.startsWith("http")); 150 const atUris = uris.filter(u => u.startsWith("at://")); 151 const CHUNK = 50; 152 153 // Build all queries upfront, then batch-execute in one D1 round trip 154 const stmts: D1PreparedStatement[] = []; 155 156 // HTTP URI queries — cards 157 const httpChunks: string[][] = []; 158 for (let i = 0; i < httpUris.length; i += CHUNK) { 159 const chunk = httpUris.slice(i, i + CHUNK); 160 httpChunks.push(chunk); 161 const placeholders = chunk.map(() => "?").join(","); 162 stmts.push( 163 db.prepare( 164 `SELECT record FROM records_card WHERE json_extract(record, '$.content.url') IN (${placeholders})` 165 ).bind(...chunk) 166 ); 167 } 168 169 // HTTP URI queries — citations 170 for (let i = 0; i < httpUris.length; i += CHUNK) { 171 const chunk = httpUris.slice(i, i + CHUNK); 172 const placeholders = chunk.map(() => "?").join(","); 173 stmts.push( 174 db.prepare( 175 `SELECT record FROM records_citation WHERE json_extract(record, '$.url') IN (${placeholders})` 176 ).bind(...chunk) 177 ); 178 } 179 180 // AT URI queries — cards 181 const atChunks: string[][] = []; 182 for (let i = 0; i < atUris.length; i += CHUNK) { 183 const chunk = atUris.slice(i, i + CHUNK); 184 atChunks.push(chunk); 185 const placeholders = chunk.map(() => "?").join(","); 186 stmts.push( 187 db.prepare(`SELECT uri, record FROM records_card WHERE uri IN (${placeholders})`).bind(...chunk) 188 ); 189 } 190 191 // AT URI queries — collections 192 for (let i = 0; i < atUris.length; i += CHUNK) { 193 const chunk = atUris.slice(i, i + CHUNK); 194 const placeholders = chunk.map(() => "?").join(","); 195 stmts.push( 196 db.prepare(`SELECT uri, record FROM records_collection WHERE uri IN (${placeholders})`).bind(...chunk) 197 ); 198 } 199 200 // Execute all queries in one batch 201 const results = stmts.length > 0 ? await db.batch(stmts) : []; 202 let resultIdx = 0; 203 204 // Process card results (HTTP) 205 for (const chunk of httpChunks) { 206 const rows = (results[resultIdx++] as { results: { record: string }[] } | null)?.results || []; 207 for (const row of rows) { 208 try { 209 const card = JSON.parse(row.record); 210 const url = card?.content?.url; 211 if (!url || !meta.has(url)) continue; 212 const m = card.content.metadata; 213 if (m?.title || m?.description) { 214 meta.set(url, { title: m.title || url, description: m.description || "", type: card.type === "URL" ? "url" : "note" }); 215 } 216 } catch {} 217 } 218 } 219 220 // Process citation results (HTTP) — overrides card data 221 for (let i = 0; i < httpChunks.length; i++) { 222 const rows = (results[resultIdx++] as { results: { record: string }[] } | null)?.results || []; 223 for (const row of rows) { 224 try { 225 const citation = JSON.parse(row.record); 226 const url = citation.url; 227 if (!url || !meta.has(url)) continue; 228 if (citation.title) { 229 const existing = meta.get(url)!; 230 meta.set(url, { title: citation.title, description: citation.takeaway || existing.description, type: "citation" }); 231 } 232 } catch {} 233 } 234 } 235 236 // Process card results (AT) 237 for (const chunk of atChunks) { 238 const rows = (results[resultIdx++] as { results: { uri: string; record: string }[] } | null)?.results || []; 239 for (const row of rows) { 240 try { 241 const card = JSON.parse(row.record); 242 const m = card?.content?.metadata; 243 const url = card?.content?.url; 244 if (meta.has(row.uri)) { 245 meta.set(row.uri, { title: m?.title || url || row.uri, description: m?.description || "", type: "card" }); 246 } 247 } catch {} 248 } 249 } 250 251 // Process collection results (AT) 252 for (let i = 0; i < atChunks.length; i++) { 253 const rows = (results[resultIdx++] as { results: { uri: string; record: string }[] } | null)?.results || []; 254 for (const row of rows) { 255 try { 256 const coll = JSON.parse(row.record); 257 if (meta.has(row.uri)) { 258 meta.set(row.uri, { title: coll.name || row.uri, description: coll.description || "", type: "collection" }); 259 } 260 } catch {} 261 } 262 } 263 264 return meta; 265} 266 267// ─── Derive island from lexmin ──────────────────────────────────── 268// 269// Given a lexmin vertex (canonical component reference), derive the 270// connected component by traversing network.cosmik.connection records. 271 272async function deriveIsland( 273 db: D1Database, 274 seed: string, 275): Promise<Island | null> { 276 await ensureContrailReady(db); 277 278 // BFS in application code — recursive CTE OOMs in D1 due to cross join 279 const visited = new Set<string>(); 280 const queue = [seed]; 281 const allEdges: Array<{ 282 uri: string; did: string; rkey: string; 283 source: string; target: string; connectionType: string; 284 note: string; handle: string | null; 285 }> = []; 286 287 while (queue.length > 0) { 288 // Drain the current queue into a batch 289 const batch = queue.splice(0, 50); 290 const toQuery = batch.filter(v => !visited.has(v)); 291 if (toQuery.length === 0) continue; 292 293 // Mark visited 294 for (const v of toQuery) visited.add(v); 295 296 // Query connections where any of these vertices are source or target 297 const placeholders = toQuery.map(() => "?").join(","); 298 const rows = await db 299 .prepare( 300 `SELECT r.record, r.did, r.rkey, i.handle 301 FROM records_connection r 302 LEFT JOIN identities i ON r.did = i.did 303 WHERE json_extract(r.record, '$.source') IN (${placeholders}) 304 OR json_extract(r.record, '$.target') IN (${placeholders})`, 305 ) 306 .bind(...toQuery, ...toQuery) 307 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 308 309 for (const row of rows.results || []) { 310 try { 311 const value = JSON.parse(row.record); 312 const source = value.source as string; 313 const target = value.target as string; 314 if (!source || !target) continue; 315 316 // Collect edge 317 const edgeKey = `${source}|${target}|${row.rkey}`; 318 allEdges.push({ 319 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 320 did: row.did, 321 rkey: row.rkey, 322 source, 323 target, 324 connectionType: value.connectionType || "relates", 325 note: value.note || "", 326 handle: row.handle, 327 }); 328 329 // Enqueue unvisited neighbors 330 if (!visited.has(source)) queue.push(source); 331 if (!visited.has(target)) queue.push(target); 332 } catch {} 333 } 334 } 335 336 if (visited.size === 0) return null; 337 338 const vertices = [...visited]; 339 340 // Filter edges to only those with both endpoints in the component 341 const vertexSet = new Set(vertices); 342 const edges: Island["edges"] = []; 343 const seenEdges = new Set<string>(); 344 for (const e of allEdges) { 345 if (!vertexSet.has(e.source) || !vertexSet.has(e.target)) continue; 346 const edgeKey = `${e.source}|${e.target}|${e.rkey}`; 347 if (seenEdges.has(edgeKey)) continue; 348 seenEdges.add(edgeKey); 349 edges.push({ 350 uri: e.uri, 351 did: e.did, 352 source: e.source, 353 target: e.target, 354 connectionType: e.connectionType, 355 note: e.note, 356 handle: e.handle, 357 }); 358 } 359 360 // Compute stable ID from lexmin 361 const sortedVertices = [...vertices].sort(); 362 const actualLexmin = sortedVertices[0]; 363 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(actualLexmin)); 364 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 365 366 return { id, vertices, edges }; 367} 368 369// ─── List strata islands from records_strata ────────────────────── 370 371// ─── Hono app ─────────────────────────────────────────────────────── 372 373let app: Hono<{ Bindings: Env }> | null = null; 374 375function buildApp(env: Env): Hono<{ Bindings: Env }> { 376 const app = new Hono<{ Bindings: Env }>(); 377 const db = env.DB; 378 379 app.use("*", cors()); 380 381 // Landing page — strata records feed 382 app.get("/", async (c) => { 383 // Load only the fields we need — records are 100KB+ with embedded connections 384 // No ensureContrailReady needed — just D1 385 let islandsJson = "[]"; 386 try { 387 const rows = await db 388 .prepare("SELECT uri, json_extract(record, '$.source.uri') as lexmin, json_extract(record, '$.analysis.title') as title FROM records_strata ORDER BY time_us DESC LIMIT 10") 389 .all<{ uri: string; lexmin: string | null; title: string | null }>(); 390 391 const islands: any[] = []; 392 for (const row of rows.results || []) { 393 if (!row.lexmin) continue; 394 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(row.lexmin)); 395 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 396 islands.push({ id, lexmin: row.lexmin, title: row.title || null, recordUri: row.uri }); 397 } 398 islandsJson = JSON.stringify(islands); 399 } catch (e) { 400 console.error("Failed to load islands:", e); 401 } 402 403 const page = LANDING_PAGE.replace( 404 "</head>", 405 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 406 ); 407 return c.html(page); 408 }); 409 410 // ── Islands API ────────────────────────────────────────────────── 411 412 app.get("/xrpc/org.latha.strata.getIslands", async (c) => { 413 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 414 const summary = c.req.query("summary") === "true"; 415 // No ensureContrailReady — just D1 queries 416 417 const rows = await db 418 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT ?") 419 .bind(limit) 420 .all<{ uri: string; record: string }>(); 421 422 const islands: any[] = []; 423 for (const row of rows.results || []) { 424 try { 425 const rec = JSON.parse(row.record); 426 const lexmin = rec.source?.uri; 427 if (!lexmin) continue; 428 429 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 430 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 431 432 // Build graph directly from embedded connections — no D1 lookups needed 433 const connections: any[] = rec.connections || []; 434 const vertexSet = new Set<string>(); 435 const edges: any[] = []; 436 437 for (const conn of connections) { 438 if (typeof conn === "object" && conn.source && conn.target) { 439 vertexSet.add(conn.source); 440 vertexSet.add(conn.target); 441 edges.push(conn); 442 } 443 } 444 445 const vertices = [...vertexSet].sort(); 446 const analysis = rec.analysis || {}; 447 448 // Summary mode: minimal payload for explore page canvas 449 // Skip vertexMeta (122KB) and trim edges to just source/target 450 const vertexMeta = summary ? {} : Object.fromEntries(await resolveVertexMeta(db, vertices)); 451 const trimmedEdges = summary 452 ? edges.map(e => ({ source: e.source, target: e.target, connectionType: e.connectionType })) 453 : edges; 454 455 islands.push({ 456 id, lexmin, vertices, edges: trimmedEdges, vertexMeta, 457 title: analysis.title || null, 458 strata: analysis.synthesis ? { 459 prose: analysis.synthesis || "", title: analysis.title || "", 460 themes: analysis.themes || [], 461 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""), 462 tensions: analysis.tensions || [], 463 open_questions: analysis.openQuestions || [], 464 synthesis: analysis.synthesis || "", 465 } : null, 466 recordUri: row.uri, 467 }); 468 } catch {} 469 } 470 471 return c.json({ islands }); 472 }); 473 474 // ── Get single island by ID or AT URI ──────────────────────────── 475 476 app.get("/xrpc/org.latha.strata.getIsland", async (c) => { 477 const id = c.req.query("id"); 478 if (!id) { 479 return c.json({ error: "MissingRequiredParameter", message: "id is required" }, 400); 480 } 481 482 await ensureContrailReady(db); 483 484 // If it's an AT URI, look up the strata record and derive from its lexmin 485 if (id.startsWith("at://")) { 486 const row = await db 487 .prepare("SELECT uri, record FROM records_strata WHERE uri = ?") 488 .bind(id) 489 .first<{ uri: string; record: string }>(); 490 491 if (!row) { 492 return c.json({ error: "RecordNotFound" }, 404); 493 } 494 495 const rec = JSON.parse(row.record); 496 const lexmin = rec.source?.uri; 497 if (!lexmin) { 498 return c.json({ error: "InvalidRecord" }, 400); 499 } 500 501 const island = await deriveIsland(db, lexmin); 502 if (!island) { 503 return c.json({ error: "IslandNotFound" }, 404); 504 } 505 506 const meta = await resolveVertexMeta(db, island.vertices); 507 const analysis = rec.analysis || {}; 508 509 return c.json({ 510 island: { 511 ...island, 512 lexmin: island.vertices.slice().sort()[0], 513 vertexMeta: Object.fromEntries(meta), 514 summary: analysis.title || null, 515 strata: { 516 prose: analysis.synthesis || "", 517 title: analysis.title || "", 518 themes: analysis.themes || [], 519 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""), 520 tensions: analysis.tensions || [], 521 open_questions: analysis.openQuestions || [], 522 synthesis: analysis.synthesis || "", 523 }, 524 }, 525 recordUri: row.uri, 526 }); 527 } 528 529 // Stable ID — derive from lexmin by reverse-lookup 530 // Find the strata record whose lexmin hashes to this ID 531 const strataRows = await db 532 .prepare("SELECT uri, record FROM records_strata") 533 .all<{ uri: string; record: string }>(); 534 535 for (const row of strataRows.results || []) { 536 try { 537 const rec = JSON.parse(row.record); 538 const lexmin = rec.source?.uri; 539 if (!lexmin) continue; 540 541 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 542 const candidateId = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 543 544 if (candidateId === id) { 545 const island = await deriveIsland(db, lexmin); 546 if (!island) continue; 547 548 const meta = await resolveVertexMeta(db, island.vertices); 549 const analysis = rec.analysis || {}; 550 551 return c.json({ 552 island: { 553 ...island, 554 lexmin: island.vertices.slice().sort()[0], 555 vertexMeta: Object.fromEntries(meta), 556 summary: analysis.title || null, 557 strata: { 558 prose: analysis.synthesis || "", 559 title: analysis.title || "", 560 themes: analysis.themes || [], 561 relationships: (analysis.connections || []).map((conn: any) => conn.description || ""), 562 tensions: analysis.tensions || [], 563 open_questions: analysis.openQuestions || [], 564 synthesis: analysis.synthesis || "", 565 }, 566 }, 567 recordUri: row.uri, 568 }); 569 } 570 } catch {} 571 } 572 573 // No strata record — try deriving island by treating the ID as a lexmin seed 574 // (for islands that haven't been analyzed yet) 575 const island = await deriveIsland(db, id); 576 if (!island) { 577 return c.json({ error: "IslandNotFound" }, 404); 578 } 579 580 const meta = await resolveVertexMeta(db, island.vertices); 581 return c.json({ 582 island: { 583 ...island, 584 lexmin: island.vertices.slice().sort()[0], 585 vertexMeta: Object.fromEntries(meta), 586 summary: null, 587 strata: null, 588 }, 589 recordUri: null, 590 }); 591 }); 592 593 // ── List strata records (AT URI-based) ────────────────────────── 594 595 app.get("/xrpc/org.latha.strata.listRecords", async (c) => { 596 const rows = await db 597 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 598 .all<{ uri: string; record: string }>(); 599 600 const records = (rows.results || []).map(r => { 601 try { 602 const rec = JSON.parse(r.record); 603 return { 604 uri: r.uri, 605 source: rec.source?.uri || "", 606 title: rec.analysis?.title || "", 607 themes: rec.analysis?.themes || [], 608 createdAt: rec.createdAt || "", 609 }; 610 } catch { 611 return null; 612 } 613 }).filter(Boolean); 614 615 return c.json({ records }); 616 }); 617 618 // ── Strata record endpoint (AT URI-based) ────────────────────── 619 620 app.get("/xrpc/org.latha.strata.getRecord", async (c) => { 621 const uri = c.req.query("uri"); 622 if (!uri) { 623 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 624 } 625 626 // Fetch the strata record from D1 (indexed by Contrail) 627 const row = await db 628 .prepare("SELECT record FROM records_strata WHERE uri = ?") 629 .bind(uri) 630 .first<{ record: string }>(); 631 632 if (!row) { 633 return c.json({ error: "NotFound", message: "Strata record not found" }, 404); 634 } 635 636 let strataRecord: any; 637 try { 638 strataRecord = JSON.parse(row.record); 639 } catch { 640 return c.json({ error: "InvalidRecord" }, 500); 641 } 642 643 // Derive island from lexmin (source.uri) 644 const lexmin = strataRecord.source?.uri; 645 if (!lexmin) { 646 return c.json({ error: "MissingLexmin", message: "Strata record has no source.uri" }, 400); 647 } 648 649 const island = await deriveIsland(db, lexmin); 650 if (!island) { 651 return c.json({ error: "IslandNotFound", message: "Could not derive island from lexmin" }, 404); 652 } 653 654 // Resolve vertex metadata 655 const meta = await resolveVertexMeta(db, island.vertices); 656 657 // Build the response in the same format as the island cache 658 const analysis = strataRecord.analysis || {}; 659 const strataData = { 660 prose: analysis.synthesis || "", 661 title: analysis.title || "", 662 themes: analysis.themes || [], 663 relationships: (analysis.connections || []).map((c: any) => c.description || ""), 664 tensions: analysis.tensions || [], 665 open_questions: analysis.openQuestions || [], 666 synthesis: analysis.synthesis || "", 667 }; 668 669 return c.json({ 670 island: { 671 ...island, 672 vertexMeta: Object.fromEntries(meta), 673 summary: analysis.title || null, 674 strata: strataData, 675 }, 676 record: strataRecord, 677 recordUri: uri, 678 }); 679 }); 680 681 // ── Container-backed xrpc methods ──────────────────────────────── 682 683 // Helper: call the strata container 684 async function callContainer(path: string, method: string, body?: any): Promise<any> { 685 const id = env.STRATA_CONTAINER.idFromName("strata"); 686 const stub = env.STRATA_CONTAINER.get(id); 687 const url = new URL(path, "http://container"); 688 const init: RequestInit = { method }; 689 if (body) { 690 init.body = JSON.stringify(body); 691 init.headers = { "Content-Type": "application/json" }; 692 } 693 const resp = await stub.fetch(new Request(url.toString(), init)); 694 return resp.json(); 695 } 696 697 // Derive islands — POST, delegates to container 698 // With seed: single island. Without: all islands. 699 app.post("/xrpc/org.latha.strata.deriveIsland", async (c) => { 700 const body = await c.req.json().catch(() => ({})) as { seed?: string }; 701 const seed = body.seed; 702 703 // Load all connections from D1 for the container to traverse 704 await ensureContrailReady(db); 705 const rows = await db 706 .prepare( 707 `SELECT r.record, r.did, r.rkey, i.handle 708 FROM records_connection r 709 LEFT JOIN identities i ON r.did = i.did`, 710 ) 711 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 712 713 const connections = (rows.results || []).map(r => { 714 try { 715 const value = JSON.parse(r.record); 716 return { 717 source: value.source, 718 target: value.target, 719 connectionType: value.connectionType || "relates", 720 note: value.note || "", 721 did: r.did, 722 rkey: r.rkey, 723 }; 724 } catch { 725 return null; 726 } 727 }).filter(Boolean); 728 729 try { 730 const result = await callContainer("/deriveIsland", "POST", { connections, seed: seed || undefined }); 731 return c.json(result); 732 } catch (e: any) { 733 return c.json({ error: "ContainerError", message: e.message }, 500); 734 } 735 }); 736 737 // Run strata analysis — delegates to container 738 app.post("/xrpc/org.latha.strata.analyze", async (c) => { 739 const body = await c.req.json().catch(() => ({})); 740 if (!body.island) { 741 return c.json({ error: "MissingRequiredField", message: "island is required" }, 400); 742 } 743 744 try { 745 const result = await callContainer("/analyze", "POST", body); 746 return c.json(result); 747 } catch (e: any) { 748 return c.json({ error: "ContainerError", message: e.message }, 500); 749 } 750 }); 751 752 // ── R2 sync endpoints ─────────────────────────────────────────── 753 754 app.put("/api/sync/vault/:did/*", async (c) => { 755 const did = c.req.param("did"); 756 const path = c.req.param("path"); 757 if (!did || !path) { 758 return c.json({ error: "Missing path" }, 400); 759 } 760 761 const key = `${did}/${path}`; 762 const body = await c.req.raw.arrayBuffer(); 763 await env.VAULT_BUCKET.put(key, body, { 764 httpMetadata: { contentType: c.req.header("content-type") || "text/markdown" }, 765 }); 766 767 return c.json({ ok: true, key }); 768 }); 769 770 app.put("/api/sync/carry/:did/*", async (c) => { 771 const did = c.req.param("did"); 772 const path = c.req.param("path"); 773 if (!did || !path) { 774 return c.json({ error: "Missing path" }, 400); 775 } 776 777 const key = `${did}/${path}`; 778 const body = await c.req.raw.arrayBuffer(); 779 await env.CARRY_BUCKET.put(key, body, { 780 httpMetadata: { contentType: c.req.header("content-type") || "application/json" }, 781 }); 782 783 return c.json({ ok: true, key }); 784 }); 785 786 // ── Graph neighborhood query ──────────────────────────────────── 787 788 app.get("/xrpc/org.latha.strata.connection.getGraph", async (c) => { 789 const uri = c.req.query("uri"); 790 if (!uri) { 791 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 792 } 793 794 const depth = Math.min(parseInt(c.req.query("depth") || "2"), 3); 795 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 796 const types = c.req.query("types")?.split(",").map((t) => t.trim()); 797 798 // Reuse island detection for the subgraph 799 const islands = await detectIslands(db); 800 const island = islands.find(i => i.vertices.includes(uri)); 801 802 if (!island) { 803 return c.json({ connections: [], resources: [], depth, uri }); 804 } 805 806 // Convert to graph format 807 const resources = island.vertices.map(v => ({ 808 uri: v, 809 title: v, 810 type: "unknown", 811 })); 812 813 return c.json({ 814 connections: island.edges, 815 resources, 816 depth, 817 uri, 818 }); 819 }); 820 821 // ── OAuth client metadata ────────────────────────────────────── 822 823 app.get("/oauth-client-metadata.json", (c) => { 824 const host = new URL(c.req.url).host; 825 return c.json({ 826 client_id: `https://${host}/oauth-client-metadata.json`, 827 client_name: "Stigmergic", 828 client_uri: `https://${host}`, 829 redirect_uris: [`https://${host}/`], 830 scope: "atproto transition:generic", 831 grant_types: ["authorization_code", "refresh_token"], 832 response_types: ["code"], 833 token_endpoint_auth_method: "none", 834 application_type: "web", 835 dpop_bound_access_tokens: true, 836 }); 837 }); 838 839 // ── SPA fallback: serve landing page for client-side routes ────── 840 for (const path of ["/island", "/strata", "/connect"]) { 841 app.get(path, async (c) => { 842 let islandsJson = "[]"; 843 try { 844 await ensureContrailReady(db); 845 const rows = await db 846 .prepare("SELECT uri, record FROM records_strata ORDER BY time_us DESC LIMIT 50") 847 .all<{ uri: string; record: string }>(); 848 const islands: any[] = []; 849 for (const row of rows.results || []) { 850 try { 851 const rec = JSON.parse(row.record); 852 const lexmin = rec.source?.uri; 853 if (!lexmin) continue; 854 const lexminHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(lexmin)); 855 const id = Array.from(new Uint8Array(lexminHash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 856 islands.push({ id, lexmin, title: rec.analysis?.title || null, recordUri: row.uri }); 857 } catch {} 858 } 859 islandsJson = JSON.stringify(islands); 860 } catch {} 861 862 const page = LANDING_PAGE.replace( 863 "</head>", 864 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 865 ); 866 return c.html(page); 867 }); 868 } 869 870 // ── All other routes pass through to contrail ────────────────── 871 872 app.all("*", async (c) => { 873 const response = await contrailWorker.fetch( 874 c.req.raw, 875 c.env as unknown as Record<string, unknown>, 876 ); 877 return response; 878 }); 879 880 return app; 881} 882 883// ─── Export ───────────────────────────────────────────────────────── 884 885export { StrataContainer }; 886 887export default { 888 fetch(request: Request, env: Env): Response | Promise<Response> { 889 app ??= buildApp(env); 890 return app.fetch(request, env); 891 }, 892 async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { 893 // Run Contrail indexing 894 await contrailWorker.scheduled( 895 event, 896 env as unknown as Record<string, unknown>, 897 ctx, 898 ); 899 }, 900}; 901