···19192020## Development
21212222-Use the top-level [`justfile`](justfile) for common workflows:
2323-2424-```bash
2525-just dev
2626-just build
2727-just test
2828-just api-run-api
2929-```
2222+Use the top-level [`justfile`](justfile) for common workflows (`just --list` to view)
30233131-The committed `apps/twisted/.env` points at production. Use `apps/twisted/.env.local`
3232-for machine-local overrides such as a localhost API or OAuth callback.
2424+Use `apps/twisted/.env.local` for machine-local overrides such as a localhost API or OAuth callback.
33253426## Run Locally
3527···10496Dev builds keep the current OAuth flow available. Production builds are read-only
10597and hide auth entry points for now.
10698107107-### Local API DB
108108-109109-The experimental local API database lives at `packages/api/twister-dev.db`.
110110-Treat it as disposable unless you explicitly back it up.
111111-112112-Operational rules:
113113-114114-1. Stop the API before copying or restoring the file.
115115-2. Copy `twister-dev.db` and any matching `-wal` or `-shm` sidecars together.
116116-3. Prefer restore-or-rebuild over manual repair if the DB looks suspect.
117117-4. Let the file grow during experiments, then compact or delete it afterward.
118118-119119-Useful local commands:
120120-121121-```bash
122122-cd packages/api
123123-du -h twister-dev.db*
124124-ls -lh twister-dev.db*
125125-```
126126-127127-## Infrastructure Setup
128128-129129-### Turso
130130-131131-Use one Turso database per environment, for example:
132132-133133-- `twister-dev`
134134-- `twister-prod`
135135-136136-Do not introduce separate app variable names for dev and prod. Always use the same variables:
137137-138138-- `TURSO_DATABASE_URL`
139139-- `TURSO_AUTH_TOKEN`
140140-141141-Only the values change per environment.
142142-143143-Example:
144144-145145-```bash
146146-# Development
147147-TURSO_DATABASE_URL=libsql://twister-dev-your-org.turso.io
148148-TURSO_AUTH_TOKEN=...
149149-150150-# Production
151151-TURSO_DATABASE_URL=libsql://twister-prod-your-org.turso.io
152152-TURSO_AUTH_TOKEN=...
153153-```
154154-155155-### Railway
9999+## Attributions
156100157157-Create or reuse one Railway project containing:
158158-159159-- existing `tap`
160160-- `api` running `twister api`
161161-- `indexer` running `twister indexer`
162162-163163-Set these shared variables on the Railway services:
164164-165165-- `TURSO_DATABASE_URL`
166166-- `TURSO_AUTH_TOKEN`
167167-- `LOG_LEVEL`
168168-- `LOG_FORMAT`
169169-170170-Set these API-specific variables:
171171-172172-- `HTTP_BIND_ADDR`
173173-- `SEARCH_DEFAULT_LIMIT`
174174-- `SEARCH_MAX_LIMIT`
175175-- `ENABLE_ADMIN_ENDPOINTS`
176176-- `ADMIN_AUTH_TOKEN`
177177-- `READ_THROUGH_MODE`
178178-- `READ_THROUGH_COLLECTIONS`
179179-- `READ_THROUGH_MAX_ATTEMPTS`
180180-181181-Set these indexer-specific variables:
182182-183183-- `TAP_URL`
184184-- `TAP_AUTH_PASSWORD`
185185-- `INDEXED_COLLECTIONS`
186186-187187-If you use separate Railway environments for dev and prod, keep the same variable names in both and only swap the Turso values.
188188-189189-### First Bootstrap
190190-191191-For a brand-new environment:
192192-193193-1. Point `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` at the target database.
194194-2. Deploy `api` and `indexer` on Railway.
195195-3. Verify API readiness and indexer health.
196196-4. Run `twister backfill` with your seed file.
197197-5. Treat the environment as search-ready only after historical backfill completes.
101101+This project relies heavily on the work of the [Tangled team](https://tangled.org/tangled.org) (duh)
102102+and the infrastructure made available by [microcosm](https://microcosm.blue), specifically
103103+Lightrail and Constellation.
+1
docs/README.md
···88- [`reference/app.md`](reference/app.md) — Ionic Vue mobile app
99- [`reference/deployment-walkthrough.md`](reference/deployment-walkthrough.md) — Railway deployment guide
1010- [`reference/lexicons.md`](reference/lexicons.md) — Tangled AT Protocol record types
1111+- [`reference/metrics.md`](reference/metrics.md) — Railway and Turso usage checks after deploy
1112- [`reference/resync.md`](reference/resync.md) — Backfill and repo-resync recovery playbook
12131314## Specs
+10-3
docs/reference/api.md
···159159160160## Backfill
161161162162-The backfill command discovers users from a seed file and registers them with Tap for indexing. Discovery fans out via follow graphs and repo collaborators up to a configurable hop depth (default 2). Supports dry-run mode, configurable concurrency and batch sizes, and is idempotent.
162162+The backfill command now defaults to `--source lightrail`: it calls
163163+`com.atproto.sync.listReposByCollection`, dedupes returned DIDs, and batch
164164+submits them to Tap. `--source graph` keeps the older seed-file follow and
165165+collaborator crawl for targeted fallback runs.
163166164167## Configuration
165168···185188| `PLC_DIRECTORY_URL` | `https://plc.directory` | PLC Directory |
186189| `XRPC_TIMEOUT` | 15s | XRPC HTTP timeout |
187190191191+Recommended production practice is to use explicit search-relevant collection
192192+lists for `INDEXED_COLLECTIONS` and `READ_THROUGH_COLLECTIONS`, not
193193+`sh.tangled.*`, and to leave `sh.tangled.graph.follow` out of both.
194194+188195## Deployment
189196190197Deployed on Railway with three services:
191198192192-- **api** — HTTP server (port 8080, health at `/healthz`)
193193-- **indexer** — Tap consumer (health at `:9090/healthz`)
199199+- **api** — HTTP server (port 8080, health at `/readyz`)
200200+- **indexer** — Tap consumer (health at `:9090/health`)
194201- **tap** — Tap instance (external dependency)
195202196203All services share the same Turso database. The API and indexer are separate deployments of the same binary with different subcommands.
+9-16
docs/reference/deployment-walkthrough.md
···2323- a Railway account and the Railway CLI
2424- a Turso database URL and auth token
2525- a Tap URL and Tap auth password
2626-- a seed list for the first backfill run
2726From this machine:
28272928```sh
···6867- `SEARCH_DEFAULT_LIMIT=20`
6968- `SEARCH_MAX_LIMIT=100`
7069- `READ_THROUGH_MODE=missing`
7171-- `READ_THROUGH_COLLECTIONS=sh.tangled.*`
7070+- `READ_THROUGH_COLLECTIONS=<explicit search collection CSV>`
7271- `READ_THROUGH_MAX_ATTEMPTS=5`
7372- `ENABLE_ADMIN_ENDPOINTS=false`
7473- `ADMIN_AUTH_TOKEN=<set this if admin routes are enabled>`
···7675- `INDEXER_HEALTH_ADDR=0.0.0.0:${{ PORT }}`
7776- `TAP_URL=<your Tap URL>`
7877- `TAP_AUTH_PASSWORD=<your Tap password>`
7979-- `INDEXED_COLLECTIONS=sh.tangled.*`
7878+- `INDEXED_COLLECTIONS=<matching explicit search collection CSV>`
8079- `ENABLE_INGEST_ENRICHMENT=true`
8080+Do not use `sh.tangled.*` for those allowlists. Match the Lightrail-backed
8181+search collection set and leave `sh.tangled.graph.follow` out.
8182Optional OAuth variables for a Railway-hosted web client metadata endpoint:
8283- `OAUTH_CLIENT_ID`
8384- `OAUTH_REDIRECT_URIS`
···1141153. Confirm the `api` domain returns `200` from `/readyz`.
1151164. Confirm the `indexer` returns `200` from `/health`.
1161175. Run the initial backfill against the same Turso and Tap environment.
117117-One simple way to run backfill from this machine is to use the same env values
118118-locally and execute:
118118+Use Railway shell so the command runs inside the live `indexer` environment:
119119120120```sh
121121-cd /Users/owais/Projects/Twisted/packages/api
122122-go run ./main.go backfill --seeds /path/to/seeds.txt
121121+cd /Users/owais/Projects/Twisted
122122+railway link # Select indexer service if prompted
123123+railway shell
124124+twister backfill --source lightrail
123125```
124126125127Do not call the environment ready until that first backfill has completed.
···139141pnpm --dir apps/twisted build
140142pnpm --dir apps/twisted exec cap sync
141143```
142142-143143-## Operating Model
144144-145145-This is the practical split:
146146-147147-- Railway hosts the always-on backend
148148-- Turso stores indexed data
149149-- this machine, or CI, builds the mobile app and points it at Railway
150150-If you later want a Railway-hosted web frontend, add that as a separate service.
+126
docs/reference/metrics.md
···11+# Metrics To Watch
22+33+Use this after deploying the Lightrail-backed backfill flow and detail-only
44+read-through changes.
55+66+## Goal
77+88+Confirm that:
99+1010+- the API stops creating broad read-through churn during browse traffic
1111+- the indexer still keeps search current through Tap
1212+- bootstrap backfills become cheaper and more predictable
1313+1414+## Railway
1515+1616+Watch both `api` and `indexer` for 24 to 48 hours after deploy.
1717+1818+### API service
1919+2020+Expected direction:
2121+2222+- lower average CPU
2323+- fewer latency spikes on browse-heavy endpoints
2424+- lower memory churn from fewer queued background jobs
2525+2626+Useful checks:
2727+2828+- CPU usage before and after deploy
2929+- memory usage before and after deploy
3030+- request latency for browse-heavy periods
3131+- restart count
3232+3333+If this change is helping, the API should look flatter under normal browsing,
3434+especially when clients hit repo lists, issue lists, pull lists, or follows.
3535+3636+### Indexer service
3737+3838+Expected direction:
3939+4040+- similar steady-state load during normal Tap ingest
4141+- shorter, more deliberate spikes only when `twister backfill` is run
4242+4343+Useful checks:
4444+4545+- CPU during normal operation
4646+- CPU during `twister backfill --source lightrail`
4747+- memory during backfill
4848+- restart count
4949+5050+The indexer may still spike during an initial bootstrap. That is expected. The
5151+important change is that the API should stop causing constant incidental work.
5252+5353+## Turso
5454+5555+This is where the clearest savings should show up.
5656+5757+Expected direction:
5858+5959+- fewer write operations
6060+- fewer row updates in indexing job tables
6161+- lower write amplification from browse traffic
6262+6363+Useful checks:
6464+6565+- total row writes
6666+- total queries
6767+- write-heavy windows during normal app usage
6868+- latency on write statements if you have it
6969+7070+The main reduction should come from no longer enqueueing whole list responses
7171+into `indexing_jobs` during browse requests.
7272+7373+## Twister Admin Signals
7474+7575+If admin endpoints are enabled, compare these before and after deploy:
7676+7777+- `read_through.pending`
7878+- `read_through.processing`
7979+- `read_through.failed`
8080+- `read_through.dead_letter`
8181+- `read_through.last_processed_at`
8282+8383+Healthy post-change behavior:
8484+8585+- pending stays near zero most of the time
8686+- processing only bumps when detail pages fetch missing records
8787+- failed and dead-letter counts grow slowly, not continuously
8888+8989+Relevant endpoint:
9090+9191+```sh
9292+curl -H "Authorization: Bearer $ADMIN_AUTH_TOKEN" http://<api-host>/admin/status
9393+```
9494+9595+## What To Compare
9696+9797+Use the same day-of-week and similar traffic windows if possible.
9898+9999+Good comparisons:
100100+101101+- 24 hours before deploy vs 24 hours after deploy
102102+- one browse-heavy period before vs after
103103+- one bootstrap backfill run before vs after
104104+105105+## Success Signals
106106+107107+Treat the rollout as successful if most of these are true:
108108+109109+- API CPU is lower or less spiky under normal browsing
110110+- Turso writes drop during browse-heavy traffic
111111+- read-through queue counts stay close to zero most of the time
112112+- backfill runs complete with fewer upstream calls and cleaner batching
113113+- search freshness still tracks Tap ingest without visible regressions
114114+115115+## Failure Signals
116116+117117+Investigate if you see any of these:
118118+119119+- search misses rise after deploy
120120+- detail pages repeatedly enqueue the same records
121121+- `read_through.pending` grows and does not drain
122122+- indexer CPU stays elevated long after a bootstrap run
123123+- Turso writes do not drop despite the handler changes
124124+125125+If that happens, inspect Tap coverage first, then spot-check whether operators
126126+ran `twister backfill --source lightrail` for the environment.
+15-11
docs/reference/resync.md
···11---
22title: Backfill & Resync Playbook
33-updated: 2026-03-25
33+updated: 2026-03-26
44---
5566Twister's search index has three recovery paths. Choose based on what broke.
···39394040### `twister backfill`
41414242-Discovers users via follow graph from seed DIDs/handles, checks Tap status for
4343-each, and registers untracked repos with Tap `/repos/add`.
4242+Defaults to `--source lightrail`: discovers DIDs from
4343+`com.atproto.sync.listReposByCollection` and submits them to Tap in batches.
4444+Use `--source graph` only for targeted fallback seeding from handles or DIDs.
44454546```sh
4646-# dry-run first
4747-twister backfill --seeds seeds.txt --max-hops 2 --dry-run
4747+# full-network dry-run first
4848+twister backfill --dry-run
48494949-# real run
5050-twister backfill --seeds seeds.txt --max-hops 2 \
5050+# full-network bootstrap
5151+twister backfill
5252+5353+# targeted fallback
5454+twister backfill --source graph --seeds seeds.txt --max-hops 2 \
5155 --concurrency 5 --batch-size 10 --batch-delay 1s
5256```
53575454-Safe to re-run. Discovery deduplicates and `repos/add` is idempotent.
5858+Safe to re-run. Discovery deduplicates and `repos/add` is treated as idempotent.
55595660### `twister reindex`
5761···1061101. Check if the DID is tracked by Tap. If not, run `backfill`:
107111108112 ```sh
109109- twister backfill --seeds <handle-or-did> --max-hops 0
113113+ twister backfill --source graph --seeds <handle-or-did> --max-hops 0
110114 ```
1111151121162. Once Tap is tracking the DID, the `indexer` will deliver historical events.
···1351392. Register repos with Tap:
136140137141 ```sh
138138- twister backfill --seeds seeds.txt --max-hops 2 --dry-run
139139- twister backfill --seeds seeds.txt --max-hops 2
142142+ twister backfill --dry-run
143143+ twister backfill
140144 ```
1411451421463. Start the indexer and let it consume: `twister indexer`
+1-2
docs/todo.md
···11---
22title: Parking Lot
33-updated: 2026-03-25
33+updated: 2026-03-26
44---
5566Search stabilization is active roadmap work now, not parking-lot work.
···88Still parked:
991010- Semantic and hybrid search stay deferred until local-only storage, smoke tests, read-through indexing, and the JetStream cache are stable.
1111-- Revisit `com.atproto.sync.listReposByCollection` as a complementary backfill discovery source after the Tap-driven indexing path is reliable.
-10
packages/api/internal/api/actors.go
···9090 if err != nil {
9191 return nil, fmt.Errorf("list repos for %s: %w", actor.DID, err)
9292 }
9393- s.enqueueXRPCList(r.Context(), entries)
94939594 for _, entry := range entries {
9695 name, _ := entry.Value["name"].(string)
···230229 s.actorError(w, err)
231230 return
232231 }
233233- s.enqueueXRPCList(r.Context(), entries)
234232235233 records := make([]recordEntry, len(entries))
236234 for i, e := range entries {
···432430 writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch issues"))
433431 return
434432 }
435435- s.enqueueXRPCList(r.Context(), issues)
436433437434 var records []issueEntry
438435 for _, e := range issues {
···470467 writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch pulls"))
471468 return
472469 }
473473- s.enqueueXRPCList(r.Context(), pulls)
474470475471 var records []pullEntry
476472 for _, e := range pulls {
···509505 writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch issues"))
510506 return
511507 }
512512- s.enqueueXRPCList(r.Context(), issues)
513508514509 records := make([]issueEntry, len(issues))
515510 for i, e := range issues {
···540535 writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch pulls"))
541536 return
542537 }
543543- s.enqueueXRPCList(r.Context(), pulls)
544538545539 records := make([]pullEntry, len(pulls))
546540 for i, e := range pulls {
···571565 writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch follows"))
572566 return
573567 }
574574- s.enqueueXRPCList(r.Context(), entries)
575568576569 records := make([]recordEntry, len(entries))
577570 for i, e := range entries {
···599592 writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch strings"))
600593 return
601594 }
602602- s.enqueueXRPCList(r.Context(), entries)
603595604596 records := make([]recordEntry, len(entries))
605597 for i, e := range entries {
···669661 writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch comments"))
670662 return
671663 }
672672- s.enqueueXRPCList(r.Context(), entries)
673664674665 var records []recordEntry
675666 for _, e := range entries {
···746737 writeJSON(w, http.StatusBadGateway, errorBody("upstream_error", "failed to fetch comments"))
747738 return
748739 }
749749- s.enqueueXRPCList(r.Context(), entries)
750740751741 var records []recordEntry
752742 for _, e := range entries {
···11-// Package backfill provides graph bootstrap tooling for Twister.
11+// Package backfill provides Tap bootstrap tooling for Twister.
22//
33// # Backfill Runbook
44//
55-// This runbook covers initial graph bootstrap and repeat runs using:
55+// This runbook covers initial bootstrap and repeat runs using:
66//
77// twister backfill
88//
99-// # Seeds Input
99+// `--source lightrail` is the default and discovers DIDs from
1010+// com.atproto.sync.listReposByCollection. `--source graph` keeps the older
1111+// handle/DID seed crawl for targeted fallback runs.
1012//
1111-// The `--seeds` flag supports either of these forms:
1313+// # Graph Seeds Input
1414+//
1515+// The `--seeds` flag applies only to `--source graph` and supports either of
1616+// these forms:
1217//
1318// 1. File path:
1419//
···3944//
4045// # First Bootstrap
4146//
4242-// 1. Copy and customize seeds:
4747+// 1. Run full-network dry-run:
4348//
4444-// cp docs/api/seeds.txt /tmp/twister-seeds.txt
4949+// twister backfill --dry-run
4550//
4646-// 2. Run dry-run first:
5151+// 2. Run real bootstrap:
4752//
4848-// twister backfill --seeds /tmp/twister-seeds.txt --max-hops 2 --dry-run
5353+// twister backfill
4954//
5050-// 3. Run real backfill:
5555+// 3. Use graph mode only for targeted fallback:
5156//
5252-// twister backfill --seeds /tmp/twister-seeds.txt --max-hops 2 --concurrency 5 --batch-size 10 --batch-delay 1s
5757+// twister backfill --source graph --seeds /tmp/twister-seeds.txt --max-hops 2
5358//
5454-// Watch logs for seed count, hop-level discoveries, already-tracked vs submitted
5555-// users, and batch progress totals.
5959+// Watch logs for discovery totals and Tap submission progress.
5660//
5761// # Repeat Run
5862//
5959-// Append new candidate users to the seed source, run dry-run, then run the real
6060-// command again. Reruns are safe because discovery deduplicates in-memory and
6161-// Tap /repos/add is treated as idempotent.
6363+// Re-run `twister backfill` whenever you need to reseed the authoritative Tap
6464+// corpus. Append graph seeds only when using `--source graph`.
6265//
6366// # Dry-Run Safety
6467//