a love letter to tangled (android, iOS, and a search API)

feat: doc site with embedded json

+821 -147
+15 -4
packages/api/internal/api/router.go
··· 102 102 } 103 103 104 104 func (s *Server) registerDocsRoutes(mux *http.ServeMux, h http.Handler) { 105 - mux.Handle("GET /docs", h) 106 - mux.Handle("GET /docs/search", h) 107 - mux.Handle("GET /docs/documents", h) 108 - mux.Handle("GET /docs/health", h) 105 + for _, path := range []string{ 106 + "GET /docs", 107 + "GET /docs/search", 108 + "GET /docs/documents", 109 + "GET /docs/health", 110 + "GET /docs/actors", 111 + "GET /docs/issues", 112 + "GET /docs/pulls", 113 + "GET /docs/identity", 114 + "GET /docs/activity", 115 + "GET /docs/profiles", 116 + "GET /docs/xrpc", 117 + } { 118 + mux.Handle(path, h) 119 + } 109 120 } 110 121 111 122 func (s *Server) registerSiteRoutes(mux *http.ServeMux) {
+408
packages/api/internal/view/api-docs.json
··· 1 + [ 2 + { 3 + "page": "search", 4 + "route": "/search", 5 + "method": "GET", 6 + "summary": "Unified search across all indexed Tangled documents.", 7 + "details": "Delegates to keyword search. The mode parameter is accepted but only \"keyword\" is supported.", 8 + "queryParams": [ 9 + { "name": "q", "type": "string", "required": true, "description": "Search query. Supports boolean operators, phrases, and prefix matching." }, 10 + { "name": "mode", "type": "string", "required": false, "description": "Search mode. Only \"keyword\" is supported." }, 11 + { "name": "limit", "type": "int", "required": false, "description": "Results per page (1–100). Default 20." }, 12 + { "name": "offset", "type": "int", "required": false, "description": "Pagination offset. Default 0." }, 13 + { "name": "collection", "type": "string", "required": false, "description": "Filter by ATProto collection NSID." }, 14 + { "name": "type", "type": "string", "required": false, "description": "Filter by record type: repo, issue, pull, profile, string." }, 15 + { "name": "author", "type": "string", "required": false, "description": "Filter by author handle or DID." }, 16 + { "name": "repo", "type": "string", "required": false, "description": "Filter by repository name." }, 17 + { "name": "language", "type": "string", "required": false, "description": "Filter by programming language." }, 18 + { "name": "state", "type": "string", "required": false, "description": "Filter by state: open, closed, merged." }, 19 + { "name": "from", "type": "string", "required": false, "description": "Created after (ISO 8601)." }, 20 + { "name": "to", "type": "string", "required": false, "description": "Created before (ISO 8601)." } 21 + ] 22 + }, 23 + { 24 + "page": "search", 25 + "route": "/search/keyword", 26 + "method": "GET", 27 + "summary": "FTS5 keyword search over indexed Tangled documents.", 28 + "details": "Same parameters and response shape as GET /search. Use this route to bypass mode negotiation.", 29 + "queryParams": [ 30 + { "name": "q", "type": "string", "required": true, "description": "Search query. Supports boolean operators, phrases, and prefix matching." }, 31 + { "name": "limit", "type": "int", "required": false, "description": "Results per page (1–100). Default 20." }, 32 + { "name": "offset", "type": "int", "required": false, "description": "Pagination offset. Default 0." }, 33 + { "name": "collection", "type": "string", "required": false, "description": "Filter by ATProto collection NSID." }, 34 + { "name": "type", "type": "string", "required": false, "description": "Filter by record type: repo, issue, pull, profile, string." }, 35 + { "name": "author", "type": "string", "required": false, "description": "Filter by author handle or DID." }, 36 + { "name": "repo", "type": "string", "required": false, "description": "Filter by repository name." }, 37 + { "name": "language", "type": "string", "required": false, "description": "Filter by programming language." }, 38 + { "name": "state", "type": "string", "required": false, "description": "Filter by state: open, closed, merged." }, 39 + { "name": "from", "type": "string", "required": false, "description": "Created after (ISO 8601)." }, 40 + { "name": "to", "type": "string", "required": false, "description": "Created before (ISO 8601)." } 41 + ] 42 + }, 43 + { 44 + "page": "documents", 45 + "route": "/documents/{id}", 46 + "method": "GET", 47 + "summary": "Fetch a single indexed document by its stable ID.", 48 + "details": "The document ID has the format did|collection|rkey. Deleted documents return 404.", 49 + "pathParams": [ 50 + { "name": "id", "type": "string", "description": "Stable document ID (did|collection|rkey)." } 51 + ] 52 + }, 53 + { 54 + "page": "actors", 55 + "route": "/actors/{handle}", 56 + "method": "GET", 57 + "summary": "Tangled actor profile with optional Bluesky social data.", 58 + "details": "Resolves handle to DID, fetches sh.tangled.actor.profile from the actor's PDS. If the profile record has bluesky:true, Bluesky display name and avatar are also returned.", 59 + "pathParams": [ 60 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." } 61 + ] 62 + }, 63 + { 64 + "page": "actors", 65 + "route": "/actors/{handle}/repos", 66 + "method": "GET", 67 + "summary": "List all sh.tangled.repo records for an actor.", 68 + "pathParams": [ 69 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." } 70 + ] 71 + }, 72 + { 73 + "page": "actors", 74 + "route": "/actors/{handle}/repos/{repo}", 75 + "method": "GET", 76 + "summary": "Repo record with resolved knot host and AT URI.", 77 + "pathParams": [ 78 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, 79 + { "name": "repo", "type": "string", "description": "Repository name." } 80 + ] 81 + }, 82 + { 83 + "page": "actors", 84 + "route": "/actors/{handle}/repos/{repo}/tree", 85 + "method": "GET", 86 + "summary": "Directory tree for a ref, proxied from the actor's knot.", 87 + "pathParams": [ 88 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, 89 + { "name": "repo", "type": "string", "description": "Repository name." } 90 + ], 91 + "queryParams": [ 92 + { "name": "ref", "type": "string", "required": false, "description": "Branch, tag, or commit SHA. Defaults to default branch." }, 93 + { "name": "path", "type": "string", "required": false, "description": "Subdirectory path within the tree." } 94 + ] 95 + }, 96 + { 97 + "page": "actors", 98 + "route": "/actors/{handle}/repos/{repo}/blob", 99 + "method": "GET", 100 + "summary": "File contents at a path and ref, proxied from the actor's knot.", 101 + "pathParams": [ 102 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, 103 + { "name": "repo", "type": "string", "description": "Repository name." } 104 + ], 105 + "queryParams": [ 106 + { "name": "ref", "type": "string", "required": false, "description": "Branch, tag, or commit SHA." }, 107 + { "name": "path", "type": "string", "required": true, "description": "File path within the repository." } 108 + ] 109 + }, 110 + { 111 + "page": "actors", 112 + "route": "/actors/{handle}/repos/{repo}/log", 113 + "method": "GET", 114 + "summary": "Commit log for a ref, proxied from the actor's knot (raw bytes).", 115 + "pathParams": [ 116 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, 117 + { "name": "repo", "type": "string", "description": "Repository name." } 118 + ], 119 + "queryParams": [ 120 + { "name": "ref", "type": "string", "required": false, "description": "Branch, tag, or commit SHA." }, 121 + { "name": "path", "type": "string", "required": false, "description": "Limit log to commits touching this path." }, 122 + { "name": "limit", "type": "int", "required": false, "description": "Max commits to return." }, 123 + { "name": "cursor", "type": "string", "required": false, "description": "Pagination cursor from a previous response." } 124 + ] 125 + }, 126 + { 127 + "page": "actors", 128 + "route": "/actors/{handle}/repos/{repo}/branches", 129 + "method": "GET", 130 + "summary": "Branch list for a repo, proxied from the actor's knot (raw bytes).", 131 + "pathParams": [ 132 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, 133 + { "name": "repo", "type": "string", "description": "Repository name." } 134 + ], 135 + "queryParams": [ 136 + { "name": "limit", "type": "int", "required": false, "description": "Max branches to return." }, 137 + { "name": "cursor", "type": "string", "required": false, "description": "Pagination cursor." } 138 + ] 139 + }, 140 + { 141 + "page": "actors", 142 + "route": "/actors/{handle}/repos/{repo}/default-branch", 143 + "method": "GET", 144 + "summary": "Default branch name for a repo, proxied from the actor's knot.", 145 + "pathParams": [ 146 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, 147 + { "name": "repo", "type": "string", "description": "Repository name." } 148 + ] 149 + }, 150 + { 151 + "page": "actors", 152 + "route": "/actors/{handle}/repos/{repo}/languages", 153 + "method": "GET", 154 + "summary": "Language breakdown for a repo at a ref, proxied from the actor's knot.", 155 + "pathParams": [ 156 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, 157 + { "name": "repo", "type": "string", "description": "Repository name." } 158 + ], 159 + "queryParams": [ 160 + { "name": "ref", "type": "string", "required": false, "description": "Branch, tag, or commit SHA." } 161 + ] 162 + }, 163 + { 164 + "page": "actors", 165 + "route": "/actors/{handle}/repos/{repo}/tags", 166 + "method": "GET", 167 + "summary": "Tag list for a repo, proxied from the actor's knot (raw bytes).", 168 + "pathParams": [ 169 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, 170 + { "name": "repo", "type": "string", "description": "Repository name." } 171 + ] 172 + }, 173 + { 174 + "page": "actors", 175 + "route": "/actors/{handle}/repos/{repo}/diff", 176 + "method": "GET", 177 + "summary": "Diff for a ref, proxied from the actor's knot (raw bytes).", 178 + "pathParams": [ 179 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, 180 + { "name": "repo", "type": "string", "description": "Repository name." } 181 + ], 182 + "queryParams": [ 183 + { "name": "ref", "type": "string", "required": false, "description": "Commit SHA or branch to diff." } 184 + ] 185 + }, 186 + { 187 + "page": "actors", 188 + "route": "/actors/{handle}/repos/{repo}/compare", 189 + "method": "GET", 190 + "summary": "Comparison between two refs, proxied from the actor's knot (raw bytes).", 191 + "pathParams": [ 192 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, 193 + { "name": "repo", "type": "string", "description": "Repository name." } 194 + ], 195 + "queryParams": [ 196 + { "name": "from", "type": "string", "required": true, "description": "Base ref (branch, tag, or SHA)." }, 197 + { "name": "to", "type": "string", "required": true, "description": "Head ref (branch, tag, or SHA)." } 198 + ] 199 + }, 200 + { 201 + "page": "actors", 202 + "route": "/actors/{handle}/repos/{repo}/issues", 203 + "method": "GET", 204 + "summary": "Issues scoped to a repo, pre-joined with state (open/closed).", 205 + "pathParams": [ 206 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, 207 + { "name": "repo", "type": "string", "description": "Repository name." } 208 + ] 209 + }, 210 + { 211 + "page": "actors", 212 + "route": "/actors/{handle}/repos/{repo}/pulls", 213 + "method": "GET", 214 + "summary": "Pull requests targeting a repo, pre-joined with status (open/closed/merged).", 215 + "pathParams": [ 216 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." }, 217 + { "name": "repo", "type": "string", "description": "Repository name." } 218 + ] 219 + }, 220 + { 221 + "page": "actors", 222 + "route": "/actors/{handle}/issues", 223 + "method": "GET", 224 + "summary": "All issues authored by an actor, pre-joined with state.", 225 + "pathParams": [ 226 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." } 227 + ] 228 + }, 229 + { 230 + "page": "actors", 231 + "route": "/actors/{handle}/pulls", 232 + "method": "GET", 233 + "summary": "All pull requests authored by an actor, pre-joined with status.", 234 + "pathParams": [ 235 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." } 236 + ] 237 + }, 238 + { 239 + "page": "actors", 240 + "route": "/actors/{handle}/following", 241 + "method": "GET", 242 + "summary": "sh.tangled.graph.follow records for an actor.", 243 + "pathParams": [ 244 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." } 245 + ] 246 + }, 247 + { 248 + "page": "actors", 249 + "route": "/actors/{handle}/strings", 250 + "method": "GET", 251 + "summary": "sh.tangled.string records posted by an actor.", 252 + "pathParams": [ 253 + { "name": "handle", "type": "string", "description": "Tangled handle or DID." } 254 + ] 255 + }, 256 + { 257 + "page": "issues", 258 + "route": "/issues/{handle}/{rkey}", 259 + "method": "GET", 260 + "summary": "Single issue with pre-joined state (open/closed).", 261 + "details": "State is derived from sh.tangled.repo.issue.state records. If no state record exists the issue is considered open.", 262 + "pathParams": [ 263 + { "name": "handle", "type": "string", "description": "Author's Tangled handle or DID." }, 264 + { "name": "rkey", "type": "string", "description": "Record key of the issue." } 265 + ] 266 + }, 267 + { 268 + "page": "issues", 269 + "route": "/issues/{handle}/{rkey}/comments", 270 + "method": "GET", 271 + "summary": "All comments for a specific issue.", 272 + "details": "Filters sh.tangled.repo.issue.comment records from the actor's PDS by matching the issue AT URI.", 273 + "pathParams": [ 274 + { "name": "handle", "type": "string", "description": "Author's Tangled handle or DID." }, 275 + { "name": "rkey", "type": "string", "description": "Record key of the parent issue." } 276 + ] 277 + }, 278 + { 279 + "page": "pulls", 280 + "route": "/pulls/{handle}/{rkey}", 281 + "method": "GET", 282 + "summary": "Single pull request with pre-joined status (open/closed/merged).", 283 + "details": "Status is derived from sh.tangled.repo.pull.status records. If no status record exists the PR is considered open.", 284 + "pathParams": [ 285 + { "name": "handle", "type": "string", "description": "Author's Tangled handle or DID." }, 286 + { "name": "rkey", "type": "string", "description": "Record key of the pull request." } 287 + ] 288 + }, 289 + { 290 + "page": "pulls", 291 + "route": "/pulls/{handle}/{rkey}/comments", 292 + "method": "GET", 293 + "summary": "All comments for a specific pull request.", 294 + "details": "Filters sh.tangled.repo.pull.comment records from the actor's PDS by matching the pull AT URI.", 295 + "pathParams": [ 296 + { "name": "handle", "type": "string", "description": "Author's Tangled handle or DID." }, 297 + { "name": "rkey", "type": "string", "description": "Record key of the parent pull request." } 298 + ] 299 + }, 300 + { 301 + "page": "identity", 302 + "route": "/identity/resolve", 303 + "method": "GET", 304 + "summary": "Resolve a handle to a DID via bsky.social.", 305 + "details": "Proxies com.atproto.identity.resolveHandle. The response shape mirrors the ATProto lexicon.", 306 + "queryParams": [ 307 + { "name": "handle", "type": "string", "required": true, "description": "Tangled or ATProto handle to resolve." } 308 + ] 309 + }, 310 + { 311 + "page": "identity", 312 + "route": "/identity/did/{did}", 313 + "method": "GET", 314 + "summary": "Fetch a DID document for did:plc or did:web identifiers.", 315 + "details": "did:plc resolves via plc.directory. did:web resolves via https://{host}/.well-known/did.json. Other DID methods return 400.", 316 + "pathParams": [ 317 + { "name": "did", "type": "string", "description": "A did:plc:… or did:web:… identifier." } 318 + ] 319 + }, 320 + { 321 + "page": "activity", 322 + "route": "/activity", 323 + "method": "GET", 324 + "summary": "Paginated list of recent ATProto events from the Jetstream cache.", 325 + "details": "Events are cached as the Jetstream firehose is consumed. Only Tangled-relevant collections are retained by default.", 326 + "queryParams": [ 327 + { "name": "limit", "type": "int", "required": false, "description": "Results per page (1–200). Default 50." }, 328 + { "name": "offset", "type": "int", "required": false, "description": "Pagination offset. Default 0." }, 329 + { "name": "collection", "type": "string", "required": false, "description": "Filter by ATProto collection NSID." }, 330 + { "name": "operation", "type": "string", "required": false, "description": "Filter by operation: create, update, delete." }, 331 + { "name": "did", "type": "string", "required": false, "description": "Filter by actor DID." } 332 + ] 333 + }, 334 + { 335 + "page": "activity", 336 + "route": "/activity/stream", 337 + "method": "GET", 338 + "summary": "WebSocket proxy to the Bluesky Jetstream firehose.", 339 + "details": "Accepts a WebSocket upgrade and forwards all query parameters to jetstream2.us-east.bsky.network. Standard Jetstream params: wantedCollections, wantedDids, cursor." 340 + }, 341 + { 342 + "page": "profiles", 343 + "route": "/profiles/{did}/summary", 344 + "method": "GET", 345 + "summary": "Social summary for an actor: follower count via Constellation.", 346 + "details": "Best-effort — if Constellation is unavailable follower_count is 0 rather than an error.", 347 + "pathParams": [ 348 + { "name": "did", "type": "string", "description": "ATProto DID, e.g. did:plc:abc." } 349 + ] 350 + }, 351 + { 352 + "page": "profiles", 353 + "route": "/backlinks/count", 354 + "method": "GET", 355 + "summary": "Constellation backlinks count for any subject/source pair.", 356 + "details": "Used by the mobile client for star counts (source: sh.tangled.graph.star) and follower counts (source: sh.tangled.graph.follow).", 357 + "queryParams": [ 358 + { "name": "subject", "type": "string", "required": true, "description": "AT URI or DID being linked to." }, 359 + { "name": "source", "type": "string", "required": true, "description": "Collection NSID of the backlink records." } 360 + ] 361 + }, 362 + { 363 + "page": "proxy", 364 + "route": "/xrpc/knot/{knotHost}/{nsid}", 365 + "method": "GET", 366 + "summary": "Proxy a GET request to a Tangled knot's XRPC endpoint.", 367 + "details": "Forwards to https://{knotHost}/xrpc/{nsid} with all query parameters. knotHost must not contain slashes or whitespace.", 368 + "pathParams": [ 369 + { "name": "knotHost", "type": "string", "description": "Knot hostname (no scheme), e.g. knot.example." }, 370 + { "name": "nsid", "type": "string", "description": "XRPC NSID to call on the knot." } 371 + ] 372 + }, 373 + { 374 + "page": "proxy", 375 + "route": "/xrpc/pds/{pds}/{nsid}", 376 + "method": "GET", 377 + "summary": "Proxy a GET request to an ATProto PDS XRPC endpoint.", 378 + "details": "Forwards to https://{pds}/xrpc/{nsid} with all query parameters. pds must not contain slashes or whitespace.", 379 + "pathParams": [ 380 + { "name": "pds", "type": "string", "description": "PDS hostname (no scheme), e.g. bsky.social." }, 381 + { "name": "nsid", "type": "string", "description": "XRPC NSID to call on the PDS." } 382 + ] 383 + }, 384 + { 385 + "page": "proxy", 386 + "route": "/xrpc/bsky/{nsid}", 387 + "method": "GET", 388 + "summary": "Proxy a GET request to the Bluesky public API.", 389 + "details": "Forwards to https://public.api.bsky.app/xrpc/{nsid}. No authentication is forwarded; only public endpoints are accessible.", 390 + "pathParams": [ 391 + { "name": "nsid", "type": "string", "description": "XRPC NSID to call on the Bluesky public API." } 392 + ] 393 + }, 394 + { 395 + "page": "health", 396 + "route": "/healthz", 397 + "method": "GET", 398 + "summary": "Liveness probe. Returns 200 if the process is responsive.", 399 + "details": "No dependency checks. Safe to call at any frequency." 400 + }, 401 + { 402 + "page": "health", 403 + "route": "/readyz", 404 + "method": "GET", 405 + "summary": "Readiness probe. Checks database reachability.", 406 + "details": "Executes a test query against the Turso/SQLite database. Returns 503 if the database cannot be reached." 407 + } 408 + ]
+46
packages/api/internal/view/docs.go
··· 1 + package view 2 + 3 + import ( 4 + _ "embed" 5 + "encoding/json" 6 + ) 7 + 8 + // DocParam describes a single path or query parameter for an endpoint. 9 + type DocParam struct { 10 + Name string `json:"name"` 11 + Type string `json:"type"` 12 + Required bool `json:"required,omitempty"` 13 + Description string `json:"description,omitempty"` 14 + } 15 + 16 + // DocEntry describes a single API endpoint. 17 + type DocEntry struct { 18 + Page string `json:"page"` 19 + Route string `json:"route"` 20 + Method string `json:"method"` 21 + Summary string `json:"summary"` 22 + Details string `json:"details,omitempty"` 23 + PathParams []DocParam `json:"pathParams,omitempty"` 24 + QueryParams []DocParam `json:"queryParams,omitempty"` 25 + } 26 + 27 + //go:embed api-docs.json 28 + var apiDocFile []byte 29 + 30 + var docsByPage map[string][]DocEntry 31 + 32 + func init() { 33 + var all []DocEntry 34 + if err := json.Unmarshal(apiDocFile, &all); err != nil { 35 + panic("api-docs.json: " + err.Error()) 36 + } 37 + docsByPage = make(map[string][]DocEntry) 38 + for _, e := range all { 39 + docsByPage[e.Page] = append(docsByPage[e.Page], e) 40 + } 41 + } 42 + 43 + // PageDocs returns the doc entries for a given page key. 44 + func PageDocs(page string) []DocEntry { 45 + return docsByPage[page] 46 + }
+10 -2
packages/api/internal/view/static/style.css
··· 229 229 .main pre code { background: none; padding: 0; } 230 230 .main p, 231 231 .main li, 232 - .main td, 233 - .main th, 234 232 .main code { 233 + overflow-wrap: anywhere; 234 + word-break: break-word; 235 + } 236 + .main th { 237 + white-space: nowrap; 238 + } 239 + .main td:not(:last-child) { 240 + white-space: nowrap; 241 + } 242 + .main td:last-child { 235 243 overflow-wrap: anywhere; 236 244 word-break: break-word; 237 245 }
+9
packages/api/internal/view/templates/docs/activity.html
··· 1 + {{define "title"}}Activity &mdash; Twister API{{end}} 2 + {{define "content"}} 3 + <h1>Activity</h1> 4 + <p>Activity endpoints expose the recent ATProto event cache and a real-time 5 + WebSocket proxy to the Bluesky Jetstream firehose. The cache is populated as 6 + the indexer consumes the firehose.</p> 7 + {{range .}}{{template "doc-entry" .}}{{end}} 8 + {{end}} 9 + {{template "layout" .}}
+9
packages/api/internal/view/templates/docs/actors.html
··· 1 + {{define "title"}}Actors &amp; Repos &mdash; Twister API{{end}} 2 + {{define "content"}} 3 + <h1>Actors &amp; Repos</h1> 4 + <p>Actor endpoints resolve Tangled handles and DIDs, fetching profile and repo 5 + data from the actor&rsquo;s PDS. Repo sub-resource endpoints proxy live data 6 + from the actor&rsquo;s assigned knot host.</p> 7 + {{range .}}{{template "doc-entry" .}}{{end}} 8 + {{end}} 9 + {{template "layout" .}}
+6 -36
packages/api/internal/view/templates/docs/documents.html
··· 1 - {{define "title"}}GET /documents/{id} &mdash; Twister API{{end}} 1 + {{define "title"}}Documents &mdash; Twister API{{end}} 2 2 {{define "content"}} 3 - <h1><code>GET /documents/{id}</code></h1> 4 - <p>Fetch a single indexed document by its stable ID.</p> 5 - 6 - <h2>Path Parameters</h2> 7 - <table> 8 - <thead><tr><th>Name</th><th>Type</th><th>Description</th></tr></thead> 9 - <tbody> 10 - <tr><td><code>id</code></td><td>string</td><td>Document stable ID (format: <code>did|collection|rkey</code>).</td></tr> 11 - </tbody> 12 - </table> 13 - 14 - <h2>Example Request</h2> 15 - <pre><code>curl "https://your-domain/documents/did:plc:abc|sh.tangled.repo|3kb3fge5lm32x"</code></pre> 16 - 17 - <h2>Example Response</h2> 18 - <pre><code>{ 19 - "id": "did:plc:abc|sh.tangled.repo|3kb3fge5lm32x", 20 - "did": "did:plc:abc", 21 - "collection": "sh.tangled.repo", 22 - "rkey": "3kb3fge5lm32x", 23 - "at_uri": "at://did:plc:abc/sh.tangled.repo/3kb3fge5lm32x", 24 - "cid": "bafyreig...", 25 - "record_type": "repo", 26 - "title": "glow-rs", 27 - "body": "A TUI markdown viewer inspired by Glow, written in Rust.", 28 - "summary": "Rust TUI markdown viewer", 29 - "repo_name": "glow-rs", 30 - "author_handle": "desertthunder.dev", 31 - "tags_json": "[\"rust\", \"tui\", \"markdown\"]", 32 - "language": "en", 33 - "created_at": "2026-03-20T10:00:00Z", 34 - "updated_at": "2026-03-22T15:03:11Z", 35 - "indexed_at": "2026-03-22T15:05:00Z" 36 - }</code></pre> 37 - 3 + <h1>Documents</h1> 4 + <p>The documents endpoint retrieves individual indexed records by their stable 5 + ID. Document IDs are returned in search results and have the form 6 + <code>did|collection|rkey</code>.</p> 7 + {{range .}}{{template "doc-entry" .}}{{end}} 38 8 <h2>Errors</h2> 39 9 <table> 40 10 <thead><tr><th>Status</th><th>Condition</th></tr></thead>
+5 -29
packages/api/internal/view/templates/docs/health.html
··· 1 - {{define "title"}}Health Endpoints &mdash; Twister API{{end}} 1 + {{define "title"}}Health &mdash; Twister API{{end}} 2 2 {{define "content"}} 3 - <h1>Health Endpoints</h1> 3 + <h1>Health</h1> 4 4 <p>Twister exposes two health check endpoints for monitoring and orchestration.</p> 5 - 6 - <h2><code>GET /healthz</code></h2> 7 - <p>Liveness probe. Returns 200 if the process is responsive. No dependency checks.</p> 8 - 9 - <h3>Example Request</h3> 10 - <pre><code>curl https://your-domain/healthz</code></pre> 11 - 12 - <h3>Response</h3> 13 - <pre><code>{"status": "ok"}</code></pre> 14 - <p>Always returns <code>200 OK</code>.</p> 15 - 16 - <h2><code>GET /readyz</code></h2> 17 - <p>Readiness probe. Checks that the database is reachable by executing a test query.</p> 18 - 19 - <h3>Example Request</h3> 20 - <pre><code>curl https://your-domain/readyz</code></pre> 21 - 22 - <h3>Response (healthy)</h3> 23 - <pre><code>{"status": "ready"}</code></pre> 24 - <p>Returns <code>200 OK</code> when the database is reachable.</p> 25 - 26 - <h3>Response (unhealthy)</h3> 27 - <pre><code>{"error": "db_unreachable", "message": "database is not reachable"}</code></pre> 28 - <p>Returns <code>503 Service Unavailable</code> when the database cannot be reached.</p> 29 - 5 + {{range .}}{{template "doc-entry" .}}{{end}} 30 6 <h2>Usage</h2> 31 - <p>Configure your orchestrator (Railway, Kubernetes, etc.) to poll these endpoints:</p> 7 + <p>Configure your orchestrator to poll these endpoints:</p> 32 8 <table> 33 - <thead><tr><th>Endpoint</th><th>Purpose</th><th>Failure Action</th></tr></thead> 9 + <thead><tr><th>Endpoint</th><th>Purpose</th><th>Failure action</th></tr></thead> 34 10 <tbody> 35 11 <tr><td><code>/healthz</code></td><td>Liveness</td><td>Restart the process</td></tr> 36 12 <tr><td><code>/readyz</code></td><td>Readiness</td><td>Remove from load balancer</td></tr>
+9
packages/api/internal/view/templates/docs/identity.html
··· 1 + {{define "title"}}Identity &mdash; Twister API{{end}} 2 + {{define "content"}} 3 + <h1>Identity</h1> 4 + <p>Identity endpoints resolve ATProto identifiers. Both routes proxy upstream 5 + services verbatim, making them safe to call from browser clients without 6 + additional CORS configuration.</p> 7 + {{range .}}{{template "doc-entry" .}}{{end}} 8 + {{end}} 9 + {{template "layout" .}}
+33 -17
packages/api/internal/view/templates/docs/index.html
··· 1 1 {{define "title"}}API Docs &mdash; Twister{{end}} 2 2 {{define "content"}} 3 3 <h1>API Documentation</h1> 4 - <p>Twister exposes a public JSON API for searching indexed <a href="https://tangled.org" target="_blank" rel="noopener">Tangled</a> content. No authentication is required for read endpoints.</p> 4 + <p>Twister exposes a public JSON API for searching indexed 5 + <a href="https://tangled.org" target="_blank" rel="noopener">Tangled</a> content. 6 + No authentication is required for read endpoints.</p> 5 7 6 8 <h2>Base URL</h2> 7 9 <pre><code>https://&lt;your-twister-domain&gt;</code></pre> 8 - <p>All endpoints are relative to the base URL. When using the search site, the API is on the same origin.</p> 10 + <p>All endpoints are relative to the base URL. When using the search site, the 11 + API is on the same origin.</p> 9 12 10 - <h2>Response Shape</h2> 11 - <p>All responses are JSON. Successful responses return the resource directly. Errors return:</p> 12 - <pre><code>{ 13 - "error": "error_code", 14 - "message": "Human-readable description" 15 - }</code></pre> 13 + <h2>Response shape</h2> 14 + <p>All responses are JSON. Successful responses return the resource directly. 15 + Errors return:</p> 16 + <pre><code>{"error": "error_code", "message": "what went wrong"}</code></pre> 16 17 17 18 <h2>Endpoints</h2> 18 19 <table> 19 - <thead><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead> 20 + <thead><tr><th>Resource</th><th>Description</th></tr></thead> 20 21 <tbody> 21 - <tr><td><code>GET</code></td><td><a href="/docs/search"><code>/search</code></a></td><td>Search indexed documents</td></tr> 22 - <tr><td><code>GET</code></td><td><a href="/docs/documents"><code>/documents/{id}</code></a></td><td>Fetch a single document</td></tr> 23 - <tr><td><code>GET</code></td><td><a href="/docs/health"><code>/healthz</code></a></td><td>Liveness probe</td></tr> 24 - <tr><td><code>GET</code></td><td><a href="/docs/health"><code>/readyz</code></a></td><td>Readiness probe</td></tr> 22 + <tr><td><a href="/docs/search">Search</a></td> 23 + <td>FTS5 keyword search over indexed documents</td></tr> 24 + <tr><td><a href="/docs/documents">Documents</a></td> 25 + <td>Fetch a single indexed document by stable ID</td></tr> 26 + <tr><td><a href="/docs/actors">Actors &amp; Repos</a></td> 27 + <td>Actor profiles, repo records, and repo sub-resources</td></tr> 28 + <tr><td><a href="/docs/issues">Issues</a></td> 29 + <td>Issue detail and comments with pre-joined state</td></tr> 30 + <tr><td><a href="/docs/pulls">Pull Requests</a></td> 31 + <td>PR detail and comments with pre-joined status</td></tr> 32 + <tr><td><a href="/docs/identity">Identity</a></td> 33 + <td>Handle resolution and DID document lookup</td></tr> 34 + <tr><td><a href="/docs/activity">Activity</a></td> 35 + <td>Cached Jetstream events and real-time WebSocket stream</td></tr> 36 + <tr><td><a href="/docs/profiles">Profiles &amp; Backlinks</a></td> 37 + <td>Social signals and Constellation backlinks counts</td></tr> 38 + <tr><td><a href="/docs/xrpc">XRPC Proxy</a></td> 39 + <td>Pass-through proxy to knots, PDS hosts, and Bluesky</td></tr> 40 + <tr><td><a href="/docs/health">Health</a></td> 41 + <td>Liveness and readiness probes</td></tr> 25 42 </tbody> 26 43 </table> 27 44 28 45 <h2>Pagination</h2> 29 - <p>List endpoints use <code>limit</code> and <code>offset</code> query parameters. The default limit is 20, maximum is 100.</p> 30 - 31 - <h2>Filtering</h2> 32 - <p>Search supports filters via query parameters: <code>collection</code>, <code>type</code>, <code>author</code>, <code>repo</code>, <code>language</code>, <code>state</code>, <code>from</code>, <code>to</code>. See the <a href="/docs/search">search endpoint docs</a> for details.</p> 46 + <p>List endpoints use <code>limit</code> and <code>offset</code> query 47 + parameters. Defaults and maximums vary per endpoint — see each resource page 48 + for details.</p> 33 49 {{end}} 34 50 {{template "layout" .}}
+9
packages/api/internal/view/templates/docs/issues.html
··· 1 + {{define "title"}}Issues &mdash; Twister API{{end}} 2 + {{define "content"}} 3 + <h1>Issues</h1> 4 + <p>Issue endpoints fetch records and comments from an actor&rsquo;s PDS. 5 + Responses include a pre-joined <code>state</code> field derived from 6 + <code>sh.tangled.repo.issue.state</code> records.</p> 7 + {{range .}}{{template "doc-entry" .}}{{end}} 8 + {{end}} 9 + {{template "layout" .}}
+9
packages/api/internal/view/templates/docs/profiles.html
··· 1 + {{define "title"}}Profiles &amp; Backlinks &mdash; Twister API{{end}} 2 + {{define "content"}} 3 + <h1>Profiles &amp; Backlinks</h1> 4 + <p>These endpoints surface derived social data from Constellation, the external 5 + graph service. Both are best-effort: counts default to zero when Constellation 6 + is not configured or unavailable.</p> 7 + {{range .}}{{template "doc-entry" .}}{{end}} 8 + {{end}} 9 + {{template "layout" .}}
+10
packages/api/internal/view/templates/docs/pulls.html
··· 1 + {{define "title"}}Pull Requests &mdash; Twister API{{end}} 2 + {{define "content"}} 3 + <h1>Pull Requests</h1> 4 + <p>Pull request endpoints fetch records and comments from an actor&rsquo;s PDS. 5 + Responses include a pre-joined <code>status</code> field derived from 6 + <code>sh.tangled.repo.pull.status</code> records: 7 + <code>open</code>, <code>closed</code>, or <code>merged</code>.</p> 8 + {{range .}}{{template "doc-entry" .}}{{end}} 9 + {{end}} 10 + {{template "layout" .}}
+9 -54
packages/api/internal/view/templates/docs/search.html
··· 1 - {{define "title"}}GET /search &mdash; Twister API{{end}} 1 + {{define "title"}}Search &mdash; Twister API{{end}} 2 2 {{define "content"}} 3 - <h1><code>GET /search</code></h1> 4 - <p>Search indexed Tangled documents with keyword matching. Returns a paginated list of results with highlighted snippets.</p> 5 - 6 - <h2>Parameters</h2> 7 - <table> 8 - <thead><tr><th>Name</th><th>Type</th><th>Default</th><th>Description</th></tr></thead> 9 - <tbody> 10 - <tr><td><code>q</code></td><td>string</td><td><em>required</em></td><td>Search query. Supports boolean operators, phrases, and prefix matching.</td></tr> 11 - <tr><td><code>mode</code></td><td>string</td><td><code>keyword</code></td><td>Search mode: <code>keyword</code>, <code>semantic</code>, or <code>hybrid</code>.</td></tr> 12 - <tr><td><code>limit</code></td><td>int</td><td><code>20</code></td><td>Results per page (1&ndash;100).</td></tr> 13 - <tr><td><code>offset</code></td><td>int</td><td><code>0</code></td><td>Pagination offset.</td></tr> 14 - <tr><td><code>collection</code></td><td>string</td><td>&mdash;</td><td>Filter by ATProto collection NSID.</td></tr> 15 - <tr><td><code>type</code></td><td>string</td><td>&mdash;</td><td>Filter by record type: <code>repo</code>, <code>issue</code>, <code>pull</code>, <code>profile</code>, <code>string</code>.</td></tr> 16 - <tr><td><code>author</code></td><td>string</td><td>&mdash;</td><td>Filter by author handle or DID.</td></tr> 17 - <tr><td><code>repo</code></td><td>string</td><td>&mdash;</td><td>Filter by repo name.</td></tr> 18 - <tr><td><code>language</code></td><td>string</td><td>&mdash;</td><td>Filter by programming language.</td></tr> 19 - <tr><td><code>state</code></td><td>string</td><td>&mdash;</td><td>Filter by state: <code>open</code>, <code>closed</code>, <code>merged</code>.</td></tr> 20 - <tr><td><code>from</code></td><td>string</td><td>&mdash;</td><td>Created after (ISO 8601).</td></tr> 21 - <tr><td><code>to</code></td><td>string</td><td>&mdash;</td><td>Created before (ISO 8601).</td></tr> 22 - </tbody> 23 - </table> 24 - 25 - <h2>Example Request</h2> 26 - <pre><code>curl "https://your-domain/search?q=rust+tui&type=repo&limit=5"</code></pre> 27 - 28 - <h2>Example Response</h2> 29 - <pre><code>{ 30 - "query": "rust tui", 31 - "mode": "keyword", 32 - "total": 12, 33 - "limit": 5, 34 - "offset": 0, 35 - "results": [ 36 - { 37 - "id": "did:plc:abc|sh.tangled.repo|3kb3fge5lm32x", 38 - "collection": "sh.tangled.repo", 39 - "record_type": "repo", 40 - "title": "glow-rs", 41 - "body_snippet": "A &lt;mark&gt;TUI&lt;/mark&gt; markdown viewer written in &lt;mark&gt;Rust&lt;/mark&gt;...", 42 - "summary": "Rust TUI markdown viewer", 43 - "repo_name": "glow-rs", 44 - "author_handle": "desertthunder.dev", 45 - "score": 0.842, 46 - "created_at": "2026-03-20T10:00:00Z", 47 - "updated_at": "2026-03-22T15:03:11Z" 48 - } 49 - ] 50 - }</code></pre> 51 - 52 - <h2>Query Syntax</h2> 53 - <p>The keyword search uses Tantivy query syntax:</p> 3 + <h1>Search</h1> 4 + <p>Search endpoints run FTS5 keyword queries over indexed Tangled documents. 5 + Results include highlighted body snippets and BM25 relevance scores. If 6 + Constellation is configured, repository results are enriched with star counts.</p> 7 + {{range .}}{{template "doc-entry" .}}{{end}} 8 + <h2>Query syntax</h2> 9 + <p>The keyword search uses SQLite FTS5 query syntax:</p> 54 10 <table> 55 11 <thead><tr><th>Feature</th><th>Example</th></tr></thead> 56 12 <tbody> ··· 61 17 <tr><td>Field-scoped</td><td><code>title:parser</code></td></tr> 62 18 </tbody> 63 19 </table> 64 - 65 20 <h2>Errors</h2> 66 21 <table> 67 22 <thead><tr><th>Status</th><th>Condition</th></tr></thead> 68 23 <tbody> 69 - <tr><td>400</td><td>Missing <code>q</code>, invalid <code>limit</code>/<code>offset</code>, unknown parameters.</td></tr> 24 + <tr><td>400</td><td>Missing <code>q</code>, invalid <code>limit</code>/<code>offset</code>, or unknown parameter.</td></tr> 70 25 <tr><td>500</td><td>Internal search failure.</td></tr> 71 26 </tbody> 72 27 </table>
+10
packages/api/internal/view/templates/docs/xrpc.html
··· 1 + {{define "title"}}XRPC &mdash; Twister API{{end}} 2 + {{define "content"}} 3 + <h1>XRPC</h1> 4 + <p>XRPC endpoints provide a single-origin pass-through to Tangled knots, PDS 5 + hosts, and the Bluesky public API. All query parameters are forwarded verbatim. 6 + Responses are streamed back unchanged, making these suitable for browser clients 7 + that cannot make direct cross-origin requests.</p> 8 + {{range .}}{{template "doc-entry" .}}{{end}} 9 + {{end}} 10 + {{template "layout" .}}
+38
packages/api/internal/view/templates/partials/doc-entry.html
··· 1 + {{define "doc-entry"}} 2 + <section class="doc-entry"> 3 + <h2><code>{{.Method}} {{.Route}}</code></h2> 4 + <p>{{.Summary}}</p> 5 + {{if .Details}}<p class="doc-details">{{.Details}}</p>{{end}} 6 + {{if .PathParams}} 7 + <h3>Path parameters</h3> 8 + <table> 9 + <thead><tr><th>Name</th><th>Type</th><th>Description</th></tr></thead> 10 + <tbody> 11 + {{range .PathParams}} 12 + <tr> 13 + <td><code>{{.Name}}</code></td> 14 + <td>{{.Type}}</td> 15 + <td>{{.Description}}</td> 16 + </tr> 17 + {{end}} 18 + </tbody> 19 + </table> 20 + {{end}} 21 + {{if .QueryParams}} 22 + <h3>Query parameters</h3> 23 + <table> 24 + <thead><tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr></thead> 25 + <tbody> 26 + {{range .QueryParams}} 27 + <tr> 28 + <td><code>{{.Name}}</code></td> 29 + <td>{{.Type}}</td> 30 + <td>{{if .Required}}yes{{else}}&mdash;{{end}}</td> 31 + <td>{{.Description}}</td> 32 + </tr> 33 + {{end}} 34 + </tbody> 35 + </table> 36 + {{end}} 37 + </section> 38 + {{end}}
+42 -5
packages/api/internal/view/view.go
··· 7 7 "net/http" 8 8 ) 9 9 10 - //go:embed templates static 10 + //go:embed templates static api-docs.json 11 11 var content embed.FS 12 12 13 13 var pageTemplates map[string]*template.Template 14 14 15 15 func init() { 16 16 pageTemplates = make(map[string]*template.Template) 17 + 17 18 for _, name := range []string{ 18 19 "index.html", 20 + } { 21 + pageTemplates[name] = template.Must(template.New("layout").ParseFS(content, 22 + "templates/layout.html", 23 + "templates/"+name, 24 + )) 25 + } 26 + 27 + for _, name := range []string{ 19 28 "docs/index.html", 20 29 "docs/search.html", 21 30 "docs/documents.html", 22 31 "docs/health.html", 32 + "docs/actors.html", 33 + "docs/issues.html", 34 + "docs/pulls.html", 35 + "docs/identity.html", 36 + "docs/activity.html", 37 + "docs/profiles.html", 38 + "docs/xrpc.html", 23 39 } { 24 40 pageTemplates[name] = template.Must(template.New("layout").ParseFS(content, 25 41 "templates/layout.html", 42 + "templates/partials/doc-entry.html", 26 43 "templates/"+name, 27 44 )) 28 45 } ··· 35 52 staticFS, _ := fs.Sub(content, "static") 36 53 mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFS))) 37 54 38 - mux.HandleFunc("GET /docs/search", renderPage("docs/search.html")) 39 - mux.HandleFunc("GET /docs/documents", renderPage("docs/documents.html")) 40 - mux.HandleFunc("GET /docs/health", renderPage("docs/health.html")) 55 + mux.HandleFunc("GET /docs/search", renderDocPage("docs/search.html", "search")) 56 + mux.HandleFunc("GET /docs/documents", renderDocPage("docs/documents.html", "documents")) 57 + mux.HandleFunc("GET /docs/health", renderDocPage("docs/health.html", "health")) 58 + mux.HandleFunc("GET /docs/actors", renderDocPage("docs/actors.html", "actors")) 59 + mux.HandleFunc("GET /docs/issues", renderDocPage("docs/issues.html", "issues")) 60 + mux.HandleFunc("GET /docs/pulls", renderDocPage("docs/pulls.html", "pulls")) 61 + mux.HandleFunc("GET /docs/identity", renderDocPage("docs/identity.html", "identity")) 62 + mux.HandleFunc("GET /docs/activity", renderDocPage("docs/activity.html", "activity")) 63 + mux.HandleFunc("GET /docs/profiles", renderDocPage("docs/profiles.html", "profiles")) 64 + mux.HandleFunc("GET /docs/xrpc", renderDocPage("docs/xrpc.html", "proxy")) 41 65 mux.HandleFunc("GET /docs", renderPage("docs/index.html")) 42 66 mux.HandleFunc("GET /{$}", renderPage("index.html")) 43 67 ··· 51 75 http.NotFound(w, r) 52 76 return 53 77 } 78 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 79 + if err := tmpl.ExecuteTemplate(w, "layout", nil); err != nil { 80 + http.Error(w, "template error", http.StatusInternalServerError) 81 + } 82 + } 83 + } 54 84 85 + func renderDocPage(name, page string) http.HandlerFunc { 86 + return func(w http.ResponseWriter, r *http.Request) { 87 + tmpl, ok := pageTemplates[name] 88 + if !ok { 89 + http.NotFound(w, r) 90 + return 91 + } 55 92 w.Header().Set("Content-Type", "text/html; charset=utf-8") 56 - if err := tmpl.ExecuteTemplate(w, "layout", nil); err != nil { 93 + if err := tmpl.ExecuteTemplate(w, "layout", PageDocs(page)); err != nil { 57 94 http.Error(w, "template error", http.StatusInternalServerError) 58 95 } 59 96 }
+144
packages/api/overview.md
··· 1 + # Twister Backend Overview 2 + 3 + Twister is a Go backend service that indexes Tangled network content and serves 4 + search queries for the Twisted mobile client. It complements Tangled's public 5 + APIs by providing global search and derived data that's hard to compute client-side. 6 + 7 + ## Core Responsibilities 8 + 9 + 1. **Real-time Indexing**: Consumes record changes from Tap (Tangled's event 10 + stream) and indexes them into SQLite FTS5 for full-text search. 11 + 12 + 2. **Search API**: Exposes HTTP endpoints for keyword search over repos, 13 + profiles, issues, pull requests, and follows. 14 + 15 + 3. **Graph Augmentation**: Caches follower relationships and provides profile 16 + summaries not easily derivable from the public knot/PDS APIs. 17 + 18 + ## Architecture 19 + 20 + ```text 21 + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ 22 + │ Tap │────▶│ Indexer │────▶│ Turso │ 23 + │ (event stream) │ (ingest) │ │ (SQLite) │ 24 + └─────────────┘ └─────────────┘ └──────┬──────┘ 25 + 26 + ┌─────────────┐ ┌─────────────┐ │ 27 + │ Twisted │────▶│ API │◀───────────┘ 28 + │ (mobile) │ │ (search) │ 29 + └─────────────┘ └─────────────┘ 30 + ``` 31 + 32 + ## Commands 33 + 34 + The `twister` binary provides six subcommands: 35 + 36 + - `api` / `serve`: HTTP server for search and proxy endpoints 37 + - `indexer`: Tap consumer that processes record events into the index 38 + - `backfill`: Discovers users from seeds and registers repos with Tap 39 + - `reindex`: Rebuilds the FTS index from existing documents 40 + - `enrich`: Backfills derived fields (handles, URLs) on existing documents 41 + - `healthcheck`: One-shot health probe 42 + 43 + ## Data Flow 44 + 45 + ### Indexing Pipeline (internal/ingest) 46 + 47 + 1. **Tap Consumer** (`internal/tapclient`): Connects to Tap WebSocket, reads 48 + record events with cursor-based resume. 49 + 50 + 2. **Normalizers** (`internal/normalize`): Collection-specific adapters convert 51 + raw records into normalized `Document` structs. Each collection (repos, 52 + issues, PRs, follows, profiles) has a dedicated adapter. 53 + 54 + 3. **Store** (`internal/store`): Upserts documents into SQLite with FTS5 index. 55 + Tracks cursor position in `sync_state` table for resume-after-restart. 56 + 57 + ### Search (internal/search) 58 + 59 + - **Keyword Search**: SQLite FTS5 full-text search with BM25 ranking 60 + - **Filters**: By collection, author, repo, language, date range, state 61 + - **Results**: Denormalized documents with snippet highlighting 62 + 63 + ## Key Packages 64 + 65 + | Package | Purpose | 66 + | ------------------------ | ------------------------------------------ | 67 + | `internal/api` | HTTP router, handlers, middleware | 68 + | `internal/store` | Database access layer (Turso/local SQLite) | 69 + | `internal/ingest` | Tap event processing pipeline | 70 + | `internal/normalize` | Record adapters for each collection type | 71 + | `internal/search` | FTS5 query execution | 72 + | `internal/backfill` | Graph discovery and Tap registration | 73 + | `internal/xrpc` | XRPC client for Tangled PDS/knot APIs | 74 + | `internal/constellation` | External service for graph queries | 75 + 76 + ## API Endpoints 77 + 78 + ### Search 79 + 80 + - `GET /search` - Unified search across all collections 81 + - `GET /search/keyword` - FTS5 keyword search 82 + 83 + ### Actors & Repos 84 + 85 + - `GET /actors/{handle}` - Profile data 86 + - `GET /actors/{handle}/repos` - User's repositories 87 + - `GET /actors/{handle}/repos/{repo}` - Repo details (tree, log, branches) 88 + 89 + ### Issues & Pulls 90 + 91 + - `GET /issues/{handle}/{rkey}` - Issue detail 92 + - `GET /pulls/{handle}/{rkey}` - Pull request detail 93 + 94 + ### Identity 95 + 96 + - `GET /identity/resolve` - Resolve handle to DID 97 + - `GET /identity/did/{did}` - DID document lookup 98 + 99 + ### Proxy 100 + 101 + - `GET /xrpc/knot/{knot}/{nsid}` - Proxy to Tangled knot 102 + - `GET /xrpc/pds/{pds}/{nsid}` - Proxy to PDS 103 + - `GET /xrpc/bsky/{nsid}` - Proxy to Bluesky 104 + 105 + ## Database Schema 106 + 107 + Core tables in Turso/libSQL: 108 + 109 + - `documents`: Denormalized search documents (title, body, metadata) 110 + - `documents_fts`: FTS5 virtual table for full-text search 111 + - `identity_handles`: DID → handle mapping with active status 112 + - `record_state`: Issue/PR state (open/closed/merged) 113 + - `sync_state`: Cursor tracking for Tap consumer resume 114 + - `indexing_jobs`: Queue for async read-through indexing 115 + 116 + ## Configuration 117 + 118 + Environment variables (loaded by `internal/config`): 119 + 120 + | Variable | Purpose | 121 + | --------------------- | ------------------------------------------ | 122 + | `HTTP_BIND_ADDR` | API server address (default `:8080`) | 123 + | `TURSO_DATABASE_URL` | libsql:// URL for Turso or file: for local | 124 + | `TURSO_AUTH_TOKEN` | Turso auth token (empty for local) | 125 + | `TAP_URL` | Tap WebSocket URL (required for indexer) | 126 + | `TAP_AUTH_PASSWORD` | Tap authentication password | 127 + | `INDEXED_COLLECTIONS` | Comma-separated allowlist (empty = all) | 128 + 129 + ## Local Development 130 + 131 + ```bash 132 + # API only (uses local SQLite file) 133 + just api-dev 134 + 135 + # Indexer only 136 + just api-run-indexer 137 + 138 + # Both in parallel (three terminals) 139 + pnpm dev # Frontend 140 + pnpm api:run:api # API 141 + pnpm api:run:indexer # Indexer 142 + ``` 143 + 144 + For remote Turso, use `--remote` flag or set `TURSO_DATABASE_URL` in `.env`.