This repository has no description
0

Configure Feed

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

stigmergic / src / worker.ts
50 kB 1402 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// ─── Env ──────────────────────────────────────────────────────────── 9 10interface Env { 11 DB: D1Database; 12 ASSETS: { fetch(request: Request): Promise<Response> }; 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 centroid: string; 38 vertices: string[]; 39 edges: Array<{ 40 uri: string; 41 did: string; 42 source: string; 43 target: string; 44 connectionType: string; 45 note: string; 46 handle?: string | null; 47 }>; 48} 49 50/** Compute centroid: vertex with highest closeness centrality (smallest avg BFS distance). */ 51function computeCentroid(adj: Map<string, Set<string>>, nodes: Set<string>): string { 52 if (nodes.size <= 1) return [...nodes][0] ?? ''; 53 54 let bestNode = ''; 55 let bestAvg = Infinity; 56 57 for (const start of nodes) { 58 const dist = new Map<string, number>(); 59 dist.set(start, 0); 60 const queue = [start]; 61 while (queue.length > 0) { 62 const current = queue.shift()!; 63 for (const neighbor of adj.get(current) || []) { 64 if (!dist.has(neighbor) && nodes.has(neighbor)) { 65 dist.set(neighbor, dist.get(current)! + 1); 66 queue.push(neighbor); 67 } 68 } 69 } 70 71 let totalDist = 0; 72 let reachable = 0; 73 for (const [node, d] of dist) { 74 if (nodes.has(node)) { 75 totalDist += d; 76 reachable++; 77 } 78 } 79 80 const avg = reachable > 0 ? totalDist / reachable : Infinity; 81 if (avg < bestAvg) { 82 bestAvg = avg; 83 bestNode = start; 84 } 85 } 86 87 return bestNode; 88} 89 90/** Hash a URL to a 12-char hex island ID (matches CLI's islandId()). */ 91async function islandHash(url: string): Promise<string> { 92 const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(url)); 93 return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 12); 94} 95 96async function detectIslands(db: D1Database): Promise<Island[]> { 97 await ensureContrailReady(db); 98 99 // Load all connections 100 const rows = await db 101 .prepare( 102 `SELECT r.record, r.did, r.rkey, i.handle 103 FROM records_connection r 104 LEFT JOIN identities i ON r.did = i.did 105 ORDER BY r.time_us DESC`, 106 ) 107 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 108 109 // Build adjacency list 110 const adj = new Map<string, Set<string>>(); 111 const edges: Island["edges"] = []; 112 113 for (const row of rows.results || []) { 114 try { 115 const value = JSON.parse(row.record); 116 const source = value.source as string; 117 const target = value.target as string; 118 if (!source || !target) continue; 119 120 if (!adj.has(source)) adj.set(source, new Set()); 121 if (!adj.has(target)) adj.set(target, new Set()); 122 adj.get(source)!.add(target); 123 adj.get(target)!.add(source); 124 125 edges.push({ 126 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 127 did: row.did, 128 source, 129 target, 130 connectionType: value.connectionType || "relates", 131 note: value.note || "", 132 handle: row.handle, 133 }); 134 } catch {} 135 } 136 137 // BFS to find connected components 138 const visited = new Set<string>(); 139 const islands: Island[] = []; 140 141 for (const node of adj.keys()) { 142 if (visited.has(node)) continue; 143 144 const component: string[] = []; 145 const queue = [node]; 146 while (queue.length > 0) { 147 const current = queue.shift()!; 148 if (visited.has(current)) continue; 149 visited.add(current); 150 component.push(current); 151 152 for (const neighbor of adj.get(current) || []) { 153 if (!visited.has(neighbor)) queue.push(neighbor); 154 } 155 } 156 157 // Only include edges where both endpoints are in this component 158 const componentSet = new Set(component); 159 const componentEdges = edges.filter( 160 e => componentSet.has(e.source) && componentSet.has(e.target), 161 ); 162 163 // Stable ID: truncated SHA-256 of the centroid vertex (highest closeness centrality) 164 const centroid = computeCentroid(adj, componentSet); 165 const id = await islandHash(centroid); 166 167 islands.push({ 168 id, 169 centroid, 170 vertices: component, 171 edges: componentEdges, 172 }); 173 } 174 175 // Sort by size descending 176 islands.sort((a, b) => b.vertices.length - a.vertices.length); 177 return islands; 178} 179 180// ─── Resolve vertex metadata (batched) ────────────────────────────── 181// 182// Bulk queries instead of N+1 per vertex. Loads all cards and citations 183// matching the given URIs in a few chunked queries. 184 185async function resolveVertexMeta( 186 db: D1Database, 187 uris: string[], 188): Promise<Map<string, { title: string; description: string; type: string }>> { 189 const meta = new Map<string, { title: string; description: string; type: string }>(); 190 for (const uri of uris) { 191 meta.set(uri, { title: uri, description: "", type: "unknown" }); 192 } 193 194 const httpUris = uris.filter(u => u.startsWith("http")); 195 const atUris = uris.filter(u => u.startsWith("at://")); 196 const CHUNK = 50; 197 198 // Build all queries upfront, then batch-execute in one D1 round trip 199 const stmts: D1PreparedStatement[] = []; 200 201 // HTTP URI queries — cards 202 const httpChunks: string[][] = []; 203 for (let i = 0; i < httpUris.length; i += CHUNK) { 204 const chunk = httpUris.slice(i, i + CHUNK); 205 httpChunks.push(chunk); 206 const placeholders = chunk.map(() => "?").join(","); 207 stmts.push( 208 db.prepare( 209 `SELECT record FROM records_card WHERE json_extract(record, '$.content.url') IN (${placeholders})` 210 ).bind(...chunk) 211 ); 212 } 213 214 // HTTP URI queries — citations 215 for (let i = 0; i < httpUris.length; i += CHUNK) { 216 const chunk = httpUris.slice(i, i + CHUNK); 217 const placeholders = chunk.map(() => "?").join(","); 218 stmts.push( 219 db.prepare( 220 `SELECT record FROM records_citation WHERE json_extract(record, '$.url') IN (${placeholders})` 221 ).bind(...chunk) 222 ); 223 } 224 225 // AT URI queries — cards 226 const atChunks: string[][] = []; 227 for (let i = 0; i < atUris.length; i += CHUNK) { 228 const chunk = atUris.slice(i, i + CHUNK); 229 atChunks.push(chunk); 230 const placeholders = chunk.map(() => "?").join(","); 231 stmts.push( 232 db.prepare(`SELECT uri, record FROM records_card WHERE uri IN (${placeholders})`).bind(...chunk) 233 ); 234 } 235 236 // AT URI queries — collections 237 for (let i = 0; i < atUris.length; i += CHUNK) { 238 const chunk = atUris.slice(i, i + CHUNK); 239 const placeholders = chunk.map(() => "?").join(","); 240 stmts.push( 241 db.prepare(`SELECT uri, record FROM records_collection WHERE uri IN (${placeholders})`).bind(...chunk) 242 ); 243 } 244 245 // Execute all queries in one batch 246 const results = stmts.length > 0 ? await db.batch(stmts) : []; 247 let resultIdx = 0; 248 249 // Process card results (HTTP) 250 for (const chunk of httpChunks) { 251 const rows = (results[resultIdx++] as { results: { record: string }[] } | null)?.results || []; 252 for (const row of rows) { 253 try { 254 const card = JSON.parse(row.record); 255 const url = card?.content?.url; 256 if (!url || !meta.has(url)) continue; 257 const m = card.content.metadata; 258 if (m?.title || m?.description) { 259 meta.set(url, { title: m.title || url, description: m.description || "", type: card.type === "URL" ? "url" : "note" }); 260 } 261 } catch {} 262 } 263 } 264 265 // Process citation results (HTTP) — overrides card data 266 for (let i = 0; i < httpChunks.length; i++) { 267 const rows = (results[resultIdx++] as { results: { record: string }[] } | null)?.results || []; 268 for (const row of rows) { 269 try { 270 const citation = JSON.parse(row.record); 271 const url = citation.url; 272 if (!url || !meta.has(url)) continue; 273 if (citation.title) { 274 const existing = meta.get(url)!; 275 meta.set(url, { title: citation.title, description: citation.takeaway || existing.description, type: "citation" }); 276 } 277 } catch {} 278 } 279 } 280 281 // Process card results (AT) 282 for (const chunk of atChunks) { 283 const rows = (results[resultIdx++] as { results: { uri: string; record: string }[] } | null)?.results || []; 284 for (const row of rows) { 285 try { 286 const card = JSON.parse(row.record); 287 const m = card?.content?.metadata; 288 const url = card?.content?.url; 289 if (meta.has(row.uri)) { 290 meta.set(row.uri, { title: m?.title || url || row.uri, description: m?.description || "", type: "card" }); 291 } 292 } catch {} 293 } 294 } 295 296 // Process collection results (AT) 297 for (let i = 0; i < atChunks.length; i++) { 298 const rows = (results[resultIdx++] as { results: { uri: string; record: string }[] } | null)?.results || []; 299 for (const row of rows) { 300 try { 301 const coll = JSON.parse(row.record); 302 if (meta.has(row.uri)) { 303 meta.set(row.uri, { title: coll.name || row.uri, description: coll.description || "", type: "collection" }); 304 } 305 } catch {} 306 } 307 } 308 309 return meta; 310} 311 312// ─── Derive island from centroid ──────────────────────────────────── 313// 314// Given a centroid vertex (canonical component reference), derive the 315// connected component by traversing network.cosmik.connection records. 316 317async function deriveIsland( 318 db: D1Database, 319 seed: string, 320): Promise<Island | null> { 321 await ensureContrailReady(db); 322 323 // BFS in application code — recursive CTE OOMs in D1 due to cross join 324 const visited = new Set<string>(); 325 const queue = [seed]; 326 const allEdges: Array<{ 327 uri: string; did: string; rkey: string; 328 source: string; target: string; connectionType: string; 329 note: string; handle: string | null; 330 }> = []; 331 332 while (queue.length > 0) { 333 // Drain the current queue into a batch 334 const batch = queue.splice(0, 50); 335 const toQuery = batch.filter(v => !visited.has(v)); 336 if (toQuery.length === 0) continue; 337 338 // Mark visited 339 for (const v of toQuery) visited.add(v); 340 341 // Query connections where any of these vertices are source or target 342 const placeholders = toQuery.map(() => "?").join(","); 343 const rows = await db 344 .prepare( 345 `SELECT r.record, r.did, r.rkey, i.handle 346 FROM records_connection r 347 LEFT JOIN identities i ON r.did = i.did 348 WHERE json_extract(r.record, '$.source') IN (${placeholders}) 349 OR json_extract(r.record, '$.target') IN (${placeholders})`, 350 ) 351 .bind(...toQuery, ...toQuery) 352 .all<{ record: string; did: string; rkey: string; handle: string | null }>(); 353 354 for (const row of rows.results || []) { 355 try { 356 const value = JSON.parse(row.record); 357 const source = value.source as string; 358 const target = value.target as string; 359 if (!source || !target) continue; 360 361 // Collect edge 362 const edgeKey = `${source}|${target}|${row.rkey}`; 363 allEdges.push({ 364 uri: `at://${row.did}/network.cosmik.connection/${row.rkey}`, 365 did: row.did, 366 rkey: row.rkey, 367 source, 368 target, 369 connectionType: value.connectionType || "relates", 370 note: value.note || "", 371 handle: row.handle, 372 }); 373 374 // Enqueue unvisited neighbors 375 if (!visited.has(source)) queue.push(source); 376 if (!visited.has(target)) queue.push(target); 377 } catch {} 378 } 379 } 380 381 if (visited.size === 0) return null; 382 383 const vertices = [...visited]; 384 385 // Filter edges to only those with both endpoints in the component 386 const vertexSet = new Set(vertices); 387 const edges: Island["edges"] = []; 388 const seenEdges = new Set<string>(); 389 for (const e of allEdges) { 390 if (!vertexSet.has(e.source) || !vertexSet.has(e.target)) continue; 391 const edgeKey = `${e.source}|${e.target}|${e.rkey}`; 392 if (seenEdges.has(edgeKey)) continue; 393 seenEdges.add(edgeKey); 394 edges.push({ 395 uri: e.uri, 396 did: e.did, 397 source: e.source, 398 target: e.target, 399 connectionType: e.connectionType, 400 note: e.note, 401 handle: e.handle, 402 }); 403 } 404 405 // Compute stable ID from centroid (highest closeness centrality) 406 const adj = new Map<string, Set<string>>(); 407 for (const e of edges) { 408 if (!adj.has(e.source)) adj.set(e.source, new Set()); 409 if (!adj.has(e.target)) adj.set(e.target, new Set()); 410 adj.get(e.source)!.add(e.target); 411 adj.get(e.target)!.add(e.source); 412 } 413 const centroid = computeCentroid(adj, vertexSet); 414 const id = await islandHash(centroid); 415 416 return { id, centroid, vertices, edges }; 417} 418 419// ─── List islands from records_island ─────────────────────────── 420 421// ─── Resolve edge URIs to full edge objects ────────────────────── 422 423/** Given a list of connections (either full objects or just {uri}), resolve URIs to full edges from D1. */ 424async function resolveEdgeUris(db: D1Database, connections: any[]): Promise<any[]> { 425 const resolved: any[] = []; 426 const uriIndices: Map<string, number[]> = new Map(); // uri → indices in connections that need it 427 428 for (let i = 0; i < connections.length; i++) { 429 const conn = connections[i]; 430 // Already has source/target — use as-is 431 if (typeof conn === "object" && conn.source && conn.target) { 432 resolved.push(conn); 433 continue; 434 } 435 // Just a URI reference — needs lookup 436 const uri = conn.uri; 437 if (!uri) continue; 438 resolved.push(null); // placeholder 439 if (!uriIndices.has(uri)) uriIndices.set(uri, []); 440 uriIndices.get(uri)!.push(i); 441 } 442 443 // Batch-fetch connection records from D1 (for URI-only references) 444 if (uriIndices.size > 0) { 445 const uris = [...uriIndices.keys()]; 446 const placeholders = uris.map(() => "?").join(","); 447 const rows = await db 448 .prepare( 449 `SELECT r.uri, r.record, r.did, r.rkey, i.handle 450 FROM records_connection r 451 LEFT JOIN identities i ON r.did = i.did 452 WHERE r.uri IN (${placeholders})`, 453 ) 454 .bind(...uris) 455 .all<{ uri: string; record: string; did: string; rkey: string; handle: string | null }>(); 456 457 for (const row of rows.results || []) { 458 try { 459 const value = JSON.parse(row.record); 460 const edge = { 461 uri: row.uri, 462 did: row.did, 463 source: value.source, 464 target: value.target, 465 connectionType: value.connectionType || "relates", 466 note: value.note || "", 467 handle: row.handle, 468 }; 469 for (const idx of uriIndices.get(row.uri) || []) { 470 resolved[idx] = edge; 471 } 472 } catch {} 473 } 474 } 475 476 // Resolve handles for inline edges (those with source/target but no did/handle) 477 // Extract DID from AT URI (at://did:plc:.../collection/rkey) and batch-lookup handles 478 const inlineEdges = resolved.filter((e: any) => e && !e.did); 479 if (inlineEdges.length > 0) { 480 const dids = new Set<string>(); 481 for (const edge of inlineEdges) { 482 const uri = (edge as any).uri as string | undefined; 483 if (!uri) continue; 484 const match = uri.match(/^at:\/\/(did:[^/]+)\//); 485 if (match) dids.add(match[1]); 486 } 487 if (dids.size > 0) { 488 const ph = [...dids].map(() => "?").join(","); 489 const rows = await db 490 .prepare(`SELECT did, handle FROM identities WHERE did IN (${ph})`) 491 .bind(...dids) 492 .all<{ did: string; handle: string | null }>(); 493 const handleMap = new Map<string, string | null>(); 494 for (const row of rows.results || []) { 495 handleMap.set(row.did, row.handle); 496 } 497 for (const edge of inlineEdges) { 498 const uri = (edge as any).uri as string | undefined; 499 if (!uri) continue; 500 const match = uri.match(/^at:\/\/(did:[^/]+)\//); 501 if (match) { 502 const did = match[1]; 503 (edge as any).did = did; 504 (edge as any).handle = handleMap.get(did) ?? null; 505 } 506 } 507 } 508 } 509 510 return resolved.filter(Boolean); 511} 512 513// ─── Hono app ─────────────────────────────────────────────────────── 514 515let app: Hono<{ Bindings: Env }> | null = null; 516 517function buildApp(env: Env): Hono<{ Bindings: Env }> { 518 const app = new Hono<{ Bindings: Env }>(); 519 const db = env.DB; 520 521 app.use("*", cors()); 522 523 // Landing page — strata records feed 524 app.get("/", async (c) => { 525 // Load only the fields we need — records are 100KB+ with embedded connections 526 // No ensureContrailReady needed — just D1 527 let islandsJson = "[]"; 528 try { 529 const rows = await db 530 .prepare("SELECT uri, json_extract(record, '$.source.uri') as centroid, json_extract(record, '$.analysis.title') as title FROM records_island ORDER BY time_us DESC LIMIT 10") 531 .all<{ uri: string; centroid: string | null; title: string | null }>(); 532 533 const islands: any[] = []; 534 for (const row of rows.results || []) { 535 if (!row.centroid) continue; 536 const id = row.uri.split("/").pop() || row.uri; 537 islands.push({ id, centroid: row.centroid, title: row.title || null, recordUri: row.uri }); 538 } 539 islandsJson = JSON.stringify(islands); 540 } catch (e) { 541 console.error("Failed to load islands:", e); 542 } 543 544 const page = LANDING_PAGE.replace( 545 "</head>", 546 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 547 ); 548 return c.html(page); 549 }); 550 551 // ── Islands API ────────────────────────────────────────────────── 552 553 app.get("/xrpc/org.latha.island.getIslands", async (c) => { 554 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 555 const summary = c.req.query("summary") === "true"; 556 // No ensureContrailReady — just D1 queries 557 558 const rows = await db 559 .prepare("SELECT uri, record FROM records_island ORDER BY time_us DESC LIMIT ?") 560 .bind(limit) 561 .all<{ uri: string; record: string }>(); 562 563 const islands: any[] = []; 564 for (const row of rows.results || []) { 565 try { 566 const rec = JSON.parse(row.record); 567 const centroid = rec.source?.uri; 568 if (!centroid) continue; 569 570 // ID is the record rkey — unique per strata record even if same graph 571 const id = row.uri.split("/").pop() || row.uri; 572 573 // Build graph from connections — resolve URIs if edges are just {uri} references 574 const connections: any[] = rec.connections || []; 575 const resolvedEdges = await resolveEdgeUris(db, connections); 576 const vertexSet = new Set<string>(); 577 const edges: any[] = []; 578 579 for (const conn of resolvedEdges) { 580 if (conn.source && conn.target) { 581 vertexSet.add(conn.source); 582 vertexSet.add(conn.target); 583 edges.push(conn); 584 } 585 } 586 587 const vertices = [...vertexSet].sort(); 588 const analysis = rec.analysis || {}; 589 590 // Summary mode: minimal payload for explore page canvas 591 // Skip vertexMeta (122KB) and trim edges to just source/target 592 const vertexMeta = summary ? {} : Object.fromEntries(await resolveVertexMeta(db, vertices)); 593 const trimmedEdges = summary 594 ? edges.map(e => ({ source: e.source, target: e.target, connectionType: e.connectionType })) 595 : edges; 596 597 islands.push({ 598 id, centroid, vertices, edges: trimmedEdges, vertexMeta, 599 title: analysis.title || null, 600 strata: analysis.synthesis || analysis.synthesisDoc ? { 601 prose: analysis.synthesis || "", title: analysis.title || "", 602 themes: analysis.themes || [], 603 relationships: [], 604 highlights: analysis.highlights || [], 605 tensions: analysis.tensions || [], 606 open_questions: analysis.openQuestions || [], 607 synthesis: analysis.synthesis || "", 608 synthesisDoc: analysis.synthesisDoc || null, 609 } : null, 610 recordUri: row.uri, 611 handle: null as string | null, 612 }); 613 } catch {} 614 } 615 616 // Batch-resolve handles for all island DIDs 617 const dids = new Set<string>(); 618 for (const island of islands) { 619 const parts = island.recordUri.split("/"); 620 if (parts[2]?.startsWith("did:")) dids.add(parts[2]); 621 } 622 if (dids.size > 0) { 623 const placeholders = [...dids].map(() => "?").join(","); 624 const handleRows = await db 625 .prepare(`SELECT did, handle FROM identities WHERE did IN (${placeholders})`) 626 .bind(...dids) 627 .all<{ did: string; handle: string | null }>(); 628 const handleMap = new Map<string, string>(); 629 for (const r of handleRows.results || []) { 630 if (r.handle) handleMap.set(r.did, r.handle); 631 } 632 for (const island of islands) { 633 const parts = island.recordUri.split("/"); 634 const did = parts[2]; 635 if (did?.startsWith("did:")) island.handle = handleMap.get(did) || null; 636 } 637 } 638 639 return c.json({ islands }); 640 }); 641 642 // ── Get single island by ID or AT URI ──────────────────────────── 643 644 app.get("/xrpc/org.latha.island.getIsland", async (c) => { 645 const id = c.req.query("id"); 646 if (!id) { 647 return c.json({ error: "MissingRequiredParameter", message: "id is required" }, 400); 648 } 649 650 // Normalize handle-based AT URIs to DID-based URIs 651 // e.g. at://nandi.latha.org/org.latha.island/rkey → at://did:plc:.../org.latha.island/rkey 652 let normalizedId = id; 653 let resolvedHandle: string | null = null; 654 if (id.startsWith("at://")) { 655 const match = id.match(/^at:\/\/([^/]+)\/(.+)$/); 656 if (match && !match[1].startsWith("did:")) { 657 const handle = match[1]; 658 const rest = match[2]; 659 const identity = await db 660 .prepare("SELECT did, handle FROM identities WHERE handle = ?") 661 .bind(handle) 662 .first<{ did: string; handle: string }>(); 663 if (!identity) { 664 return c.json({ error: "IdentityNotFound", message: `Handle ${handle} not found` }, 404); 665 } 666 normalizedId = `at://${identity.did}/${rest}`; 667 resolvedHandle = identity.handle; 668 } 669 } 670 671 // If it's an AT URI, look up the strata record 672 if (normalizedId.startsWith("at://")) { 673 // Batch: fetch record + resolve handle in one D1 round trip 674 const did = normalizedId.split("/")[2]; 675 const stmts: D1PreparedStatement[] = [ 676 db.prepare("SELECT uri, record FROM records_island WHERE uri = ?").bind(normalizedId), 677 ]; 678 if (!resolvedHandle && did?.startsWith("did:")) { 679 stmts.push(db.prepare("SELECT handle FROM identities WHERE did = ?").bind(did)); 680 } 681 const batchResults = await db.batch(stmts); 682 683 const row = (batchResults[0] as { results: { uri: string; record: string }[] }).results?.[0]; 684 if (!row) { 685 return c.json({ error: "RecordNotFound" }, 404); 686 } 687 688 if (!resolvedHandle && batchResults[1]) { 689 const identity = (batchResults[1] as { results: { handle: string }[] }).results?.[0]; 690 resolvedHandle = identity?.handle || null; 691 } 692 693 const rec = JSON.parse(row.record); 694 const centroid = rec.source?.uri; 695 if (!centroid) { 696 return c.json({ error: "InvalidRecord" }, 400); 697 } 698 699 // Build graph from record's connections — no BFS needed 700 const connections: any[] = rec.connections || []; 701 const resolvedEdges = await resolveEdgeUris(db, connections); 702 const vertexSet = new Set<string>(); 703 const edges: any[] = []; 704 for (const conn of resolvedEdges) { 705 if (conn.source && conn.target) { 706 vertexSet.add(conn.source); 707 vertexSet.add(conn.target); 708 edges.push(conn); 709 } 710 } 711 const vertices = [...vertexSet].sort(); 712 713 // Compute centroid and island ID 714 const adj = new Map<string, Set<string>>(); 715 for (const e of edges) { 716 if (!adj.has(e.source)) adj.set(e.source, new Set()); 717 if (!adj.has(e.target)) adj.set(e.target, new Set()); 718 adj.get(e.source)!.add(e.target); 719 adj.get(e.target)!.add(e.source); 720 } 721 const actualCentroid = computeCentroid(adj, vertexSet); 722 const islandId = await islandHash(actualCentroid); 723 724 const meta = await resolveVertexMeta(db, vertices); 725 const analysis = rec.analysis || {}; 726 727 return c.json({ 728 island: { 729 id: islandId, 730 centroid: actualCentroid, 731 vertices, 732 edges, 733 vertexMeta: Object.fromEntries(meta), 734 summary: analysis.title || null, 735 handle: resolvedHandle, 736 rawRecord: rec, 737 strata: analysis.synthesis ? { 738 prose: analysis.synthesis || "", 739 title: analysis.title || "", 740 themes: analysis.themes || [], 741 relationships: [], 742 highlights: analysis.highlights || [], 743 tensions: analysis.tensions || [], 744 open_questions: analysis.openQuestions || [], 745 synthesis: analysis.synthesis || "", 746 synthesisDoc: analysis.synthesisDoc || null, 747 } : null, 748 }, 749 recordUri: row.uri, 750 }); 751 } 752 753 // ID is the record rkey — look up directly by URI 754 const recordUri = `at://did:plc:ngokl2gnmpbvuvrfckja3g7p/org.latha.island/${id}`; 755 const row = await db 756 .prepare("SELECT uri, record FROM records_island WHERE uri = ?") 757 .bind(recordUri) 758 .first<{ uri: string; record: string }>(); 759 760 if (row) { 761 try { 762 const rec = JSON.parse(row.record); 763 const centroid = rec.source?.uri; 764 765 // Build graph from connections — resolve URIs if edges are just {uri} references 766 const connections: any[] = rec.connections || []; 767 const resolvedEdges = await resolveEdgeUris(db, connections); 768 const vertexSet = new Set<string>(); 769 const edges: any[] = []; 770 for (const conn of resolvedEdges) { 771 if (conn.source && conn.target) { 772 vertexSet.add(conn.source); 773 vertexSet.add(conn.target); 774 edges.push(conn); 775 } 776 } 777 const vertices = [...vertexSet].sort(); 778 const meta = await resolveVertexMeta(db, vertices); 779 const analysis = rec.analysis || {}; 780 781 return c.json({ 782 island: { 783 id, 784 vertices, 785 edges, 786 centroid: centroid || vertices[0], 787 vertexMeta: Object.fromEntries(meta), 788 summary: analysis.title || null, 789 strata: { 790 prose: analysis.synthesis || "", 791 title: analysis.title || "", 792 themes: analysis.themes || [], 793 relationships: [], 794 highlights: analysis.highlights || [], 795 tensions: analysis.tensions || [], 796 open_questions: analysis.openQuestions || [], 797 synthesis: analysis.synthesis || "", 798 synthesisDoc: analysis.synthesisDoc || null, 799 }, 800 recordUri: row.uri, 801 }, 802 }); 803 } catch {} 804 } 805 806 // No strata record found — try deriving island by treating the ID as a centroid seed 807 // If the ID is a 12-char hex string (centroid hash), scan strata records for a match 808 if (/^[0-9a-f]{12}$/.test(id)) { 809 const rows = await db 810 .prepare("SELECT uri, record FROM records_island ORDER BY time_us DESC LIMIT 500") 811 .all<{ uri: string; record: string }>(); 812 for (const row of rows.results || []) { 813 try { 814 const rec = JSON.parse(row.record); 815 const centroid = rec.source?.uri; 816 if (!centroid) continue; 817 const hash = await islandHash(centroid); 818 if (hash === id) { 819 // Found the record — redirect to the AT URI path 820 const connections: any[] = rec.connections || []; 821 const resolvedEdges = await resolveEdgeUris(db, connections); 822 const vertexSet = new Set<string>(); 823 const edges: any[] = []; 824 for (const conn of resolvedEdges) { 825 if (conn.source && conn.target) { 826 vertexSet.add(conn.source); 827 vertexSet.add(conn.target); 828 edges.push(conn); 829 } 830 } 831 const vertices = [...vertexSet].sort(); 832 const meta = await resolveVertexMeta(db, vertices); 833 const analysis = rec.analysis || {}; 834 return c.json({ 835 island: { 836 id, 837 vertices, 838 edges, 839 centroid, 840 vertexMeta: Object.fromEntries(meta), 841 summary: analysis.title || null, 842 strata: { 843 prose: analysis.synthesis || "", 844 title: analysis.title || "", 845 themes: analysis.themes || [], 846 relationships: [], 847 highlights: analysis.highlights || [], 848 tensions: analysis.tensions || [], 849 open_questions: analysis.openQuestions || [], 850 synthesis: analysis.synthesis || "", 851 synthesisDoc: analysis.synthesisDoc || null, 852 }, 853 }, 854 recordUri: row.uri, 855 }); 856 } 857 } catch {} 858 } 859 } 860 861 // Fall back: treat ID as a vertex URL for BFS derivation 862 const island = await deriveIsland(db, id); 863 if (!island) { 864 return c.json({ error: "IslandNotFound" }, 404); 865 } 866 867 const meta = await resolveVertexMeta(db, island.vertices); 868 return c.json({ 869 island: { 870 ...island, 871 centroid: island.centroid, 872 vertexMeta: Object.fromEntries(meta), 873 summary: null, 874 strata: null, 875 }, 876 recordUri: null, 877 }); 878 }); 879 880 // ── List strata records (AT URI-based) ────────────────────────── 881 882 app.get("/xrpc/org.latha.island.listRecords", async (c) => { 883 const rows = await db 884 .prepare("SELECT uri, record FROM records_island ORDER BY time_us DESC LIMIT 50") 885 .all<{ uri: string; record: string }>(); 886 887 const records = (rows.results || []).map(r => { 888 try { 889 const rec = JSON.parse(r.record); 890 return { 891 uri: r.uri, 892 source: rec.source?.uri || "", 893 title: rec.analysis?.title || "", 894 themes: rec.analysis?.themes || [], 895 createdAt: rec.createdAt || "", 896 }; 897 } catch { 898 return null; 899 } 900 }).filter(Boolean); 901 902 return c.json({ records }); 903 }); 904 905 // ── Search islands by URL ──────────────────────────────────────── 906 907 app.get("/xrpc/org.latha.island.searchIslands", async (c) => { 908 const url = c.req.query("url"); 909 if (!url) { 910 return c.json({ error: "MissingRequiredParameter", message: "url is required" }, 400); 911 } 912 913 // Normalize the query URL 914 let normalizedUrl: string; 915 let queryDomain: string; 916 try { 917 const u = new URL(url); 918 u.hash = ""; 919 if (u.hostname.startsWith("www.")) u.hostname = u.hostname.slice(4); 920 if (u.pathname.endsWith("/")) u.pathname = u.pathname.slice(0, -1); 921 normalizedUrl = u.toString(); 922 queryDomain = u.hostname.toLowerCase(); 923 } catch { 924 return c.json({ error: "InvalidUrl", message: "Could not parse URL" }, 400); 925 } 926 927 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 928 const rows = await db 929 .prepare("SELECT uri, record FROM records_island ORDER BY time_us DESC LIMIT ?") 930 .bind(limit) 931 .all<{ uri: string; record: string }>(); 932 933 const results: any[] = []; 934 for (const row of rows.results || []) { 935 try { 936 const rec = JSON.parse(row.record); 937 const centroid = rec.source?.uri; 938 if (!centroid) continue; 939 940 const connections: any[] = rec.connections || []; 941 const resolvedEdges = await resolveEdgeUris(db, connections); 942 const vertexSet = new Set<string>(); 943 const edges: any[] = []; 944 for (const conn of resolvedEdges) { 945 if (conn.source && conn.target) { 946 vertexSet.add(conn.source); 947 vertexSet.add(conn.target); 948 edges.push(conn); 949 } 950 } 951 const vertices = [...vertexSet].sort(); 952 953 // Check for exact and domain matches 954 let matchType: "exact" | "domain" | null = null; 955 const matchedUrls: string[] = []; 956 for (const v of vertices) { 957 let vNorm: string; 958 try { 959 const vu = new URL(v); 960 vu.hash = ""; 961 if (vu.hostname.startsWith("www.")) vu.hostname = vu.hostname.slice(4); 962 if (vu.pathname.endsWith("/")) vu.pathname = vu.pathname.slice(0, -1); 963 vNorm = vu.toString(); 964 } catch { vNorm = v; } 965 if (vNorm === normalizedUrl) { 966 matchType = "exact"; 967 matchedUrls.push(v); 968 } else if (!matchType) { 969 try { 970 const vDomain = new URL(v).hostname.toLowerCase(); 971 if (vDomain === queryDomain) { 972 matchType = "domain"; 973 matchedUrls.push(v); 974 } 975 } catch {} 976 } 977 } 978 979 if (!matchType) continue; 980 981 const id = row.uri.split("/").pop() || row.uri; 982 const analysis = rec.analysis || {}; 983 const did = row.uri.split("/")[2] || ""; 984 const handleRow = await db 985 .prepare("SELECT handle FROM identities WHERE did = ?") 986 .bind(did) 987 .first<{ handle: string | null }>(); 988 989 results.push({ 990 id, 991 centroid, 992 title: analysis.title || null, 993 recordUri: row.uri, 994 handle: handleRow?.handle || null, 995 matchType, 996 matchedUrls, 997 nodeCount: vertices.length, 998 edgeCount: edges.length, 999 edges: edges.map(e => ({ source: e.source, target: e.target, connectionType: e.connectionType })), 1000 strata: analysis.synthesis ? { 1001 title: analysis.title || "", 1002 themes: analysis.themes || [], 1003 synthesis: analysis.synthesis || "", 1004 tensions: analysis.tensions || [], 1005 openQuestions: analysis.openQuestions || [], 1006 highlights: analysis.highlights || [], 1007 } : null, 1008 }); 1009 } catch {} 1010 } 1011 1012 // Sort: exact before domain 1013 results.sort((a, b) => (a.matchType === "exact" ? -1 : 1) - (b.matchType === "exact" ? -1 : 1)); 1014 1015 return c.json({ url, results }); 1016 }); 1017 1018 // ── Strata record endpoint (AT URI-based) ────────────────────── 1019 1020 app.get("/xrpc/org.latha.island.getRecord", async (c) => { 1021 const uri = c.req.query("uri"); 1022 if (!uri) { 1023 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 1024 } 1025 1026 // Fetch the strata record from D1 (indexed by Contrail) 1027 const row = await db 1028 .prepare("SELECT record FROM records_island WHERE uri = ?") 1029 .bind(uri) 1030 .first<{ record: string }>(); 1031 1032 if (!row) { 1033 return c.json({ error: "NotFound", message: "Strata record not found" }, 404); 1034 } 1035 1036 let strataRecord: any; 1037 try { 1038 strataRecord = JSON.parse(row.record); 1039 } catch { 1040 return c.json({ error: "InvalidRecord" }, 500); 1041 } 1042 1043 // Derive island from centroid (source.uri) 1044 const centroid = strataRecord.source?.uri; 1045 if (!centroid) { 1046 return c.json({ error: "MissingLexmin", message: "Strata record has no source.uri" }, 400); 1047 } 1048 1049 const island = await deriveIsland(db, centroid); 1050 if (!island) { 1051 return c.json({ error: "IslandNotFound", message: "Could not derive island from centroid" }, 404); 1052 } 1053 1054 // Resolve vertex metadata 1055 const meta = await resolveVertexMeta(db, island.vertices); 1056 1057 // Build the response in the same format as the island cache 1058 const analysis = strataRecord.analysis || {}; 1059 const strataData = { 1060 prose: analysis.synthesis || "", 1061 title: analysis.title || "", 1062 themes: analysis.themes || [], 1063 relationships: [], 1064 highlights: analysis.highlights || [], 1065 tensions: analysis.tensions || [], 1066 open_questions: analysis.openQuestions || [], 1067 synthesis: analysis.synthesis || "", 1068 synthesisDoc: analysis.synthesisDoc || null, 1069 }; 1070 1071 return c.json({ 1072 island: { 1073 ...island, 1074 vertexMeta: Object.fromEntries(meta), 1075 summary: analysis.title || null, 1076 strata: strataData, 1077 rawRecord: strataRecord, 1078 }, 1079 record: strataRecord, 1080 recordUri: uri, 1081 }); 1082 }); 1083 1084 // Notify appview of a newly published record — crawl the DID's PDS and upsert into D1 1085 app.post("/xrpc/org.latha.island.notifyOfUpdate", async (c) => { 1086 const body = await c.req.json().catch(() => ({})) as { did?: string; uri?: string }; 1087 const did = body.did; 1088 if (!did) return c.json({ error: "InvalidRequest", message: "did is required" }, 400); 1089 1090 await ensureContrailReady(db); 1091 1092 // Look up the DID's PDS 1093 const identity = await db 1094 .prepare("SELECT pds FROM identities WHERE did = ?") 1095 .bind(did) 1096 .first<{ pds: string }>(); 1097 1098 const pds = identity?.pds; 1099 if (!pds) return c.json({ error: "IdentityNotFound", message: `DID ${did} not in identities table` }, 404); 1100 1101 // Fetch org.latha.island records from the DID's PDS 1102 const url = `${pds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=org.latha.island&limit=50`; 1103 const resp = await fetch(url); 1104 if (!resp.ok) return c.json({ error: "PdsError", message: `PDS returned ${resp.status}` }, 502); 1105 1106 const data = await resp.json() as { records: Array<{ uri: string; cid: string; value: Record<string, unknown> }> }; 1107 const records = data.records || []; 1108 if (records.length === 0) return c.json({ ingested: 0 }); 1109 1110 // Upsert each record into records_island 1111 const now = Date.now() * 1000; // time_us 1112 const indexedAt = Date.now(); 1113 const stmt = db.prepare( 1114 `INSERT INTO records_island (uri, did, rkey, cid, record, time_us, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(uri) DO UPDATE SET cid = excluded.cid, record = excluded.record, time_us = excluded.time_us, indexed_at = excluded.indexed_at` 1115 ); 1116 1117 let ingested = 0; 1118 const batch: D1PreparedStatement[] = []; 1119 for (const r of records) { 1120 const rkey = r.uri.split("/").pop() || ""; 1121 batch.push(stmt.bind(r.uri, did, rkey, r.cid || "", JSON.stringify(r.value), now + ingested, indexedAt)); 1122 ingested++; 1123 } 1124 1125 await db.batch(batch); 1126 return c.json({ ingested }); 1127 }); 1128 1129 // ── R2 sync endpoints ─────────────────────────────────────────── 1130 1131 app.put("/api/sync/vault/:did/*", async (c) => { 1132 const did = c.req.param("did"); 1133 const path = c.req.param("path"); 1134 if (!did || !path) { 1135 return c.json({ error: "Missing path" }, 400); 1136 } 1137 1138 const key = `${did}/${path}`; 1139 const body = await c.req.raw.arrayBuffer(); 1140 await env.VAULT_BUCKET.put(key, body, { 1141 httpMetadata: { contentType: c.req.header("content-type") || "text/markdown" }, 1142 }); 1143 1144 return c.json({ ok: true, key }); 1145 }); 1146 1147 app.put("/api/sync/carry/:did/*", async (c) => { 1148 const did = c.req.param("did"); 1149 const path = c.req.param("path"); 1150 if (!did || !path) { 1151 return c.json({ error: "Missing path" }, 400); 1152 } 1153 1154 const key = `${did}/${path}`; 1155 const body = await c.req.raw.arrayBuffer(); 1156 await env.CARRY_BUCKET.put(key, body, { 1157 httpMetadata: { contentType: c.req.header("content-type") || "application/json" }, 1158 }); 1159 1160 return c.json({ ok: true, key }); 1161 }); 1162 1163 // ── Graph neighborhood query ──────────────────────────────────── 1164 1165 app.get("/xrpc/org.latha.island.connection.getGraph", async (c) => { 1166 const uri = c.req.query("uri"); 1167 if (!uri) { 1168 return c.json({ error: "MissingRequiredParameter", message: "uri is required" }, 400); 1169 } 1170 1171 const depth = Math.min(parseInt(c.req.query("depth") || "2"), 3); 1172 const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); 1173 const types = c.req.query("types")?.split(",").map((t) => t.trim()); 1174 1175 // Reuse island detection for the subgraph 1176 const islands = await detectIslands(db); 1177 const island = islands.find(i => i.vertices.includes(uri)); 1178 1179 if (!island) { 1180 return c.json({ connections: [], resources: [], depth, uri }); 1181 } 1182 1183 // Convert to graph format 1184 const resources = island.vertices.map(v => ({ 1185 uri: v, 1186 title: v, 1187 type: "unknown", 1188 })); 1189 1190 return c.json({ 1191 connections: island.edges, 1192 resources, 1193 depth, 1194 uri, 1195 }); 1196 }); 1197 1198 // ── OAuth client metadata ────────────────────────────────────── 1199 1200 app.get("/oauth-client-metadata.json", (c) => { 1201 const host = new URL(c.req.url).host; 1202 return c.json({ 1203 client_id: `https://${host}/oauth-client-metadata.json`, 1204 client_name: "Stigmergic", 1205 client_uri: `https://${host}`, 1206 redirect_uris: [`https://${host}/`, `https://${host}/oauth/callback`], 1207 scope: "atproto transition:generic", 1208 grant_types: ["authorization_code", "refresh_token"], 1209 response_types: ["code"], 1210 token_endpoint_auth_method: "none", 1211 application_type: "web", 1212 dpop_bound_access_tokens: true, 1213 }); 1214 }); 1215 1216 app.get("/oauth/callback", (c) => c.html(landingPage())); 1217 1218 app.all("/debug/echo/*", (c) => c.json({ path: c.req.path, url: c.req.url })); 1219 app.all("/debug/echo", (c) => c.json({ path: c.req.path, url: c.req.url })); 1220 1221 // Static assets are files only. App routing stays in this Worker. 1222 app.get("/app.js", (c) => c.env.ASSETS.fetch(c.req.raw)); 1223 app.get("/app.js.map", (c) => c.env.ASSETS.fetch(c.req.raw)); 1224 1225 // ── SPA fallback: serve landing page for client-side routes ────── 1226 1227 // /island/<handle>/<rkey> — inject island data for instant hydration 1228 app.get("/island/:repo/:rkey", async (c) => { 1229 let repo = c.req.param("repo"); 1230 const rkey = c.req.param("rkey"); 1231 1232 let islandsJson = "[]"; 1233 let islandDataJson = "null"; 1234 try { 1235 // Batch: island list + specific island record 1236 const atUri = repo.startsWith("did:") 1237 ? `at://${repo}/org.latha.island/${rkey}` 1238 : `at://${repo}/org.latha.island/${rkey}`; 1239 1240 // Resolve handle → DID if needed 1241 let did = repo; 1242 let handle: string | null = null; 1243 if (!repo.startsWith("did:")) { 1244 const identity = await db 1245 .prepare("SELECT did, handle FROM identities WHERE handle = ?") 1246 .bind(repo) 1247 .first<{ did: string; handle: string }>(); 1248 if (identity) { 1249 did = identity.did; 1250 handle = identity.handle; 1251 } 1252 } else { 1253 const identity = await db 1254 .prepare("SELECT handle FROM identities WHERE did = ?") 1255 .bind(repo) 1256 .first<{ handle: string }>(); 1257 handle = identity?.handle || null; 1258 } 1259 1260 const fullAtUri = `at://${did}/org.latha.island/${rkey}`; 1261 1262 // Batch: fetch record + island list 1263 const stmts = [ 1264 db.prepare("SELECT uri, record FROM records_island WHERE uri = ?").bind(fullAtUri), 1265 db.prepare("SELECT uri, json_extract(record, '$.source.uri') as centroid, json_extract(record, '$.analysis.title') as title FROM records_island ORDER BY time_us DESC LIMIT 50"), 1266 ]; 1267 const [recordResult, listResult] = await db.batch(stmts); 1268 1269 // Build island list 1270 const listRows = (listResult as { results: { uri: string; centroid: string | null; title: string | null }[] }).results || []; 1271 const islands: any[] = []; 1272 for (const row of listRows) { 1273 if (!row.centroid) continue; 1274 const id = row.uri.split("/").pop() || row.uri; 1275 const listDid = row.uri.split("/")[2]; 1276 // Resolve handle for list items lazily — skip for now, use DID 1277 islands.push({ id, centroid: row.centroid, title: row.title || null, recordUri: row.uri, handle: listDid === did ? handle : null }); 1278 } 1279 islandsJson = JSON.stringify(islands); 1280 1281 // Build island data from record 1282 const recordRow = (recordResult as { results: { uri: string; record: string }[] }).results?.[0]; 1283 if (recordRow) { 1284 const rec = JSON.parse(recordRow.record); 1285 const centroid = rec.source?.uri; 1286 if (centroid) { 1287 const connections: any[] = rec.connections || []; 1288 const resolvedEdges = await resolveEdgeUris(db, connections); 1289 const vertexSet = new Set<string>(); 1290 const edges: any[] = []; 1291 for (const conn of resolvedEdges) { 1292 if (conn.source && conn.target) { 1293 vertexSet.add(conn.source); 1294 vertexSet.add(conn.target); 1295 edges.push(conn); 1296 } 1297 } 1298 const vertices = [...vertexSet].sort(); 1299 const adj = new Map<string, Set<string>>(); 1300 for (const e of edges) { 1301 if (!adj.has(e.source)) adj.set(e.source, new Set()); 1302 if (!adj.has(e.target)) adj.set(e.target, new Set()); 1303 adj.get(e.source)!.add(e.target); 1304 adj.get(e.target)!.add(e.source); 1305 } 1306 const actualCentroid = computeCentroid(adj, vertexSet); 1307 const islandId = await islandHash(actualCentroid); 1308 const meta = await resolveVertexMeta(db, vertices); 1309 const analysis = rec.analysis || {}; 1310 1311 islandDataJson = JSON.stringify({ 1312 island: { 1313 id: islandId, 1314 centroid: actualCentroid, 1315 vertices, 1316 edges, 1317 vertexMeta: Object.fromEntries(meta), 1318 summary: analysis.title || null, 1319 handle, 1320 strata: analysis.synthesis || analysis.synthesisDoc ? { 1321 prose: analysis.synthesis || "", 1322 title: analysis.title || "", 1323 themes: analysis.themes || [], 1324 relationships: [], 1325 highlights: analysis.highlights || [], 1326 tensions: analysis.tensions || [], 1327 open_questions: analysis.openQuestions || [], 1328 synthesis: analysis.synthesis || "", 1329 synthesisDoc: analysis.synthesisDoc || null, 1330 } : null, 1331 }, 1332 recordUri: recordRow.uri, 1333 }); 1334 } 1335 } 1336 } catch (e) { 1337 console.error("Failed to load island data:", e); 1338 } 1339 1340 const page = LANDING_PAGE.replace( 1341 "</head>", 1342 `<script>window.__ISLANDS__=${islandsJson};window.__ISLAND_DATA__=${islandDataJson};</script></head>`, 1343 ); 1344 return c.html(page); 1345 }); 1346 1347 // Other SPA routes — just inject island list 1348 for (const path of ["/island", "/strata", "/connect", "/search"]) { 1349 app.get(path, async (c) => { 1350 let islandsJson = "[]"; 1351 try { 1352 const rows = await db 1353 .prepare("SELECT uri, json_extract(record, '$.source.uri') as centroid, json_extract(record, '$.analysis.title') as title FROM records_island ORDER BY time_us DESC LIMIT 50") 1354 .all<{ uri: string; centroid: string | null; title: string | null }>(); 1355 const islands: any[] = []; 1356 for (const row of rows.results || []) { 1357 if (!row.centroid) continue; 1358 const id = row.uri.split("/").pop() || row.uri; 1359 islands.push({ id, centroid: row.centroid, title: row.title || null, recordUri: row.uri }); 1360 } 1361 islandsJson = JSON.stringify(islands); 1362 } catch {} 1363 1364 const page = LANDING_PAGE.replace( 1365 "</head>", 1366 `<script>window.__ISLANDS__=${islandsJson};</script></head>`, 1367 ); 1368 return c.html(page); 1369 }); 1370 } 1371 1372 // ── All other routes pass through to contrail ────────────────── 1373 1374 app.all("*", async (c) => { 1375 const response = await contrailWorker.fetch( 1376 c.req.raw, 1377 c.env as unknown as Record<string, unknown>, 1378 ); 1379 return response; 1380 }); 1381 1382 return app; 1383} 1384 1385// ─── Export ───────────────────────────────────────────────────────── 1386 1387export { StrataContainer }; 1388 1389export default { 1390 fetch(request: Request, env: Env): Response | Promise<Response> { 1391 app ??= buildApp(env); 1392 return app.fetch(request, env); 1393 }, 1394 async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { 1395 // Run Contrail indexing 1396 await contrailWorker.scheduled( 1397 event, 1398 env as unknown as Record<string, unknown>, 1399 ctx, 1400 ); 1401 }, 1402};