···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+| `searchable` | auto-detected | FTS5 search fields. `string[]` = explicit fields, `false` = disabled, omitted = all non-range queryable fields |
77787879### Profiles
7980···8889| `{collection}.listRecords` | List/filter records |
8990| `{collection}.getRecord` | Get single record by URI |
9091| `{namespace}.getProfile` | Get a user's profile by DID or handle |
9292+| `{namespace}.notifyOfUpdate` | Notify of a record change for immediate indexing |
9193| `{namespace}.admin.sync` | Discover + backfill (requires `ADMIN_SECRET`) |
9294| `{namespace}.admin.getCursor` | Current cursor position |
9395| `{namespace}.admin.getOverview` | All collections summary |
···101103|-------|---------|-------------|
102104| `actor` | `?actor=did:plc:...` or `?actor=alice.bsky.social` | Filter by DID or handle (triggers on-demand backfill) |
103105| `profiles` | `?profiles=true` | Include profile + identity info keyed by DID |
106106+| `search` | `?search=meetup` | Full-text search across searchable fields (FTS5, ranked) |
104107| `{field}` | `?status=going` | Equality filter on queryable string field |
105108| `{field}Min` | `?startsAtMin=2026-03-16` | Range minimum (datetime/integer fields) |
106109| `{field}Max` | `?endsAtMax=2026-04-01` | Range maximum (datetime/integer fields) |
107110| `{rel}CountMin` | `?rsvpsCountMin=10` | Minimum total relation count |
108111| `{rel}{Group}CountMin` | `?rsvpsGoingCountMin=10` | Minimum relation count for a specific groupBy value |
109109-| `hydrate` | `?hydrate=rsvps:10` | Embed latest N related records per record |
112112+| `hydrate{Rel}` | `?hydrateRsvps=10` | Embed latest N related records (per group if grouped) |
113113+| `hydrate{Ref}` | `?hydrateEvent=true` | Embed the referenced record |
114114+| `sort` | `?sort=startsAt` | Sort by a queryable field or count (see below) |
115115+| `order` | `?order=asc` | Sort direction: `asc` or `desc` (default depends on field type) |
110116| `limit` | `?limit=25` | Page size (1-100, default 50) |
111117| `cursor` | `?cursor=...` | Pagination cursor |
112118113113-**Hydration** returns related records grouped by `groupBy` value:
119119+**Sorting** — `sort` accepts any queryable field param name or a count field:
114120115121```
116116-?hydrate=rsvps:5 # latest 5 per group (going, interested, etc.)
117117-?hydrate=rsvps:5&hydrate=followers:10 # multiple hydrations
122122+?sort=startsAt # by date (default: desc for range fields)
123123+?sort=name&order=asc # by name ascending
124124+?sort=rsvpsCount # by total RSVP count (default: desc)
125125+?sort=rsvpsGoingCount&order=asc # by going count ascending
126126+```
127127+128128+**Search** uses SQLite FTS5 for ranked full-text search. By default, all non-range queryable fields are searchable. Results are ranked by relevance (BM25) with `time_us` as tiebreaker. Supports FTS5 syntax including prefix (`meetup*`), phrases (`"rust meetup"`), and boolean (`rust OR typescript`). Combinable with all other filters.
129129+130130+```
131131+?search=meetup # basic search
132132+?search=meetup&mode=online # search + filter
133133+?search=rust*&sort=startsAt&order=asc # search + sort override
134134+```
135135+136136+**Hydration** embeds related or referenced records inline:
137137+138138+```
139139+?hydrateRsvps=5 # latest 5 RSVPs per group (going, interested, etc.)
140140+?hydrateEvent=true # embed the referenced event record
141141+?hydrateRsvps=5&hydrateEvent=true # combine both
118142```
119143120144### Examples (events)
121145122146```
123147# Upcoming events with 10+ going RSVPs, with RSVP records and profiles
124124-/xrpc/community.lexicon.calendar.event.getRecords?startsAtMin=2026-03-16&rsvpsGoingCountMin=10&hydrate=rsvps:5&profiles=true
148148+/xrpc/community.lexicon.calendar.event.listRecords?startsAtMin=2026-03-16&rsvpsGoingCountMin=10&hydrateRsvps=5&profiles=true
125149126150# Events for a specific user (by handle)
127127-/xrpc/community.lexicon.calendar.event.getRecords?actor=alice.bsky.social&profiles=true
151151+/xrpc/community.lexicon.calendar.event.listRecords?actor=alice.bsky.social&profiles=true
128152129153# Single event with counts, RSVPs, and profiles
130130-/xrpc/community.lexicon.calendar.event.getRecord?uri=at://did:plc:.../community.lexicon.calendar.event/...&hydrate=rsvps:10&profiles=true
154154+/xrpc/community.lexicon.calendar.event.getRecord?uri=at://did:plc:.../community.lexicon.calendar.event/...&hydrateRsvps=10&profiles=true
155155+156156+# Search for events by name/description
157157+/xrpc/community.lexicon.calendar.event.listRecords?search=meetup&profiles=true
158158+159159+# RSVPs for a specific event, with the referenced event embedded
160160+/xrpc/community.lexicon.calendar.rsvp.listRecords?subjectUri=at://did:plc:.../community.lexicon.calendar.event/...&hydrateEvent=true&profiles=true
161161+```
162162+163163+## Notify of Updates
164164+165165+By default, Contrail ingests from Jetstream every minute. If your app writes to a user's PDS and needs the change reflected immediately, call `notifyOfUpdate` right after the write:
166166+167167+```ts
168168+// User creates an RSVP via their PDS
169169+const { uri } = await agent.createRecord({ ... });
170170+171171+// Tell Contrail to fetch and index it now
172172+await fetch("https://your-contrail.workers.dev/xrpc/com.example.notifyOfUpdate", {
173173+ method: "POST",
174174+ headers: { "Content-Type": "application/json" },
175175+ body: JSON.stringify({ uri }),
176176+});
177177+```
178178+179179+Contrail fetches the record from the user's PDS and figures out what to do:
180180+181181+| PDS returns | Already indexed? | Action |
182182+|---|---|---|
183183+| Record (new CID) | No | **Create** — indexes it, updates relation counts |
184184+| Record (new CID) | Yes | **Update** — upserts the record |
185185+| Record (same CID) | Yes | **Skip** — nothing changed |
186186+| 404 | Yes | **Delete** — removes it, decrements counts |
187187+| 404 | No | **No-op** |
131188132132-# RSVPs for a specific event with profiles
133133-/xrpc/community.lexicon.calendar.rsvp.getRecords?subjectUri=at://did:plc:.../community.lexicon.calendar.event/...&profiles=true
189189+You can also batch up to 25 URIs in one request:
190190+191191+```ts
192192+await fetch(".../xrpc/com.example.notifyOfUpdate", {
193193+ method: "POST",
194194+ headers: { "Content-Type": "application/json" },
195195+ body: JSON.stringify({ uris: [uri1, uri2, uri3] }),
196196+});
134197```
198198+199199+When Jetstream later delivers the same event, the duplicate is detected by CID and skipped.
135200136201## Typesafe Client Usage
137202