This repository has no description
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};