···11+# Contrail
22+33+> [!WARNING]
44+> Work in progress! Pre-alpha, expect breaking changes.
55+66+Define collections — get automatic Jetstream ingestion, PDS backfill, user discovery, and typed XRPC endpoints. Runs on Cloudflare Workers + D1.
77+88+## Quickstart
99+1010+### Dev
1111+1212+```bash
1313+pnpm install
1414+# Edit src/config.ts with your collections
1515+pnpm generate:pull # pull lexicons from network, auto-detect fields, generate types
1616+pnpm dev:auto # start wrangler dev with auto-ingestion
1717+pnpm sync # discover users + backfill records from PDS
1818+```
1919+2020+### Production
2121+2222+```bash
2323+wrangler d1 create contrail
2424+# Add database_id to wrangler.toml
2525+wrangler secret put ADMIN_SECRET
2626+pnpm deploy
2727+# to sync in production, run it locally but set your d1 to remote, then run
2828+pnpm sync
2929+```
3030+3131+Ingestion runs automatically via cron (`*/1 * * * *`). Schema is auto-initialized.
3232+3333+## Config
3434+3535+Edit `src/config.ts` — this is the only file you need to touch:
3636+3737+```ts
3838+export const config: ContrailConfig = {
3939+ collections: {
4040+ "community.lexicon.calendar.event": {
4141+ relations: {
4242+ rsvps: {
4343+ collection: "community.lexicon.calendar.rsvp",
4444+ groupBy: "status", // materialized counts by status
4545+ },
4646+ },
4747+ },
4848+ "community.lexicon.calendar.rsvp": {},
4949+ },
5050+ // profiles: ["app.bsky.actor.profile"], ← default
5151+ // jetstreams: [...] ← default: 4 Bluesky jetstream endpoints
5252+ // relays: [...] ← default: 2 Bluesky relay endpoints
5353+};
5454+```
5555+5656+### What's auto-detected from lexicons
5757+5858+When you run `pnpm generate`, queryable fields are derived from each collection's lexicon:
5959+6060+- **String fields** → equality filter (`?status=going`)
6161+- **Datetime/integer fields** → range filter (`?min=startsAt:2026-03-16`)
6262+- **StrongRef fields** → `.uri` equality filter (`?subjectUri=at://...`)
6363+6464+You can override any auto-detected field by specifying `queryable` manually in config.
6565+6666+### Collection options
6767+6868+| Option | Default | Description |
6969+|--------|---------|-------------|
7070+| `queryable` | auto-detected | Override auto-detected queryable fields |
7171+| `discover` | `true` | Find users via relays. `false` = only track known DIDs |
7272+| `relations` | `{}` | Many-to-one relationships with materialized counts |
7373+| `relations.*.field` | `"subject.uri"` | Field in the related record to match against |
7474+| `relations.*.match` | `"uri"` | Match against parent's `"uri"` or `"did"` |
7575+| `relations.*.groupBy` | — | Split counts by this field's value |
7676+| `queries` | `{}` | Custom query handlers |
7777+7878+### Profiles
7979+8080+`profiles` is a top-level config array of collection NSIDs that contain profile records (rkey `self`). Defaults to `["app.bsky.actor.profile"]`. These are auto-added to `collections` with `{ discover: false }`. Use `?profiles=true` on any endpoint to include a `profiles` map in the response, keyed by DID, with handle and profile record data.
8181+8282+## API
8383+8484+All endpoints at `/xrpc/{nsid}.{method}`:
8585+8686+| Endpoint | Description |
8787+|----------|-------------|
8888+| `{nsid}.getRecords` | List/filter records |
8989+| `{nsid}.getRecord` | Get single record by URI |
9090+| `{nsid}.getUsers` | List users by record count |
9191+| `{nsid}.getStats` | Collection statistics |
9292+| `contrail.admin.sync` | Discover + backfill (requires `ADMIN_SECRET`) |
9393+| `contrail.admin.getCursor` | Current cursor position |
9494+| `contrail.admin.getOverview` | All collections summary |
9595+9696+### Query parameters
9797+9898+**Filtering:**
9999+100100+| Param | Example | Description |
101101+|-------|---------|-------------|
102102+| `actor` | `?actor=did:plc:...` or `?actor=alice.bsky.social` | Filter by DID or handle (triggers on-demand backfill) |
103103+| `profiles` | `?profiles=true` | Include profile + identity info keyed by DID |
104104+| `{field}` | `?status=going` | Equality filter on queryable string field |
105105+| `min` | `?min=startsAt:2026-03-16` | Range minimum (datetime/integer fields) or count minimum |
106106+| `max` | `?max=endsAt:2026-04-01` | Range maximum (datetime/integer fields) |
107107+| `hydrate` | `?hydrate=rsvps:10` | Embed latest N related records per record |
108108+| `limit` | `?limit=25` | Page size (1-100, default 50) |
109109+| `cursor` | `?cursor=...` | Pagination cursor |
110110+111111+**Count filters** use the same `min`/`max` syntax with the count type as key:
112112+113113+```
114114+# events with 10+ "going" RSVPs,
115115+# take care to parse the `#` character correctly in your HTTP client
116116+?min=community.lexicon.calendar.rsvp#going:10
117117+```
118118+119119+**Hydration** returns related records grouped by `groupBy` value:
120120+121121+```
122122+?hydrate=rsvps:5 # latest 5 per group (going, interested, etc.)
123123+?hydrate=rsvps:5&hydrate=followers:10 # multiple hydrations
124124+```
125125+126126+### Examples (events)
127127+128128+```
129129+# Upcoming events with 10+ going, with RSVP records and profiles
130130+/xrpc/community.lexicon.calendar.event.getRecords?min=startsAt:2026-03-16&min=community.lexicon.calendar.rsvp#going:10&hydrate=rsvps:5&profiles=true
131131+132132+# Events for a specific user (by handle)
133133+/xrpc/community.lexicon.calendar.event.getRecords?actor=alice.bsky.social&profiles=true
134134+135135+# Single event with counts, RSVPs, and profiles
136136+/xrpc/community.lexicon.calendar.event.getRecord?uri=at://did:plc:.../community.lexicon.calendar.event/...&hydrate=rsvps:10&profiles=true
137137+138138+# RSVPs for a specific event with profiles
139139+/xrpc/community.lexicon.calendar.rsvp.getRecords?subjectUri=at://did:plc:.../community.lexicon.calendar.event/...&profiles=true
140140+```