Querying
Once indexing is set up, every collection you declared gets a pair of XRPC endpoints under /xrpc/{namespace}.{short}.*:
| Endpoint | Returns |
|---|---|
{namespace}.{short}.listRecords | Paginated list with filters, sorts, hydration |
{namespace}.{short}.getRecord?uri=… | Single record by AT-URI |
Plus a few top-level ones: {namespace}.getProfile, {namespace}.getCursor, {namespace}.getOverview, {namespace}.notifyOfUpdate, {namespace}.permissionSet, {namespace}.lexicons.
HTTP (what most callers use)
Every config field becomes a predictable URL param:
/xrpc/com.example.event.listRecords?mode=online&startsAtMin=2026-01-01&rsvpsGoingCountMin=10&sort=startsAt&order=asc&hydrateRsvps=5
/xrpc/com.example.event.getRecord?uri=at://did:plc:.../...&hydrateRsvps=5
| Config produces | URL param |
|---|---|
queryable: { field: {} } | ?field=value (equality) |
queryable: { field: { type: "range" } } | ?fieldMin=…, ?fieldMax=… |
relations: { rel: {...} } | ?relCountMin=N, ?sort=relCount, ?hydrateRel=N |
relations: { rel: { groups: { going } } } | ?relGoingCountMin=N, ?sort=relGoingCount |
references: { ref: {...} } | ?hydrateRef=true |
Dotted field names become camelCase params — queryable: { "subject.uri": {} } → ?subjectUri=….
Programmatic
const { records, cursor } = await contrail.query("event", {
filters: { mode: "online" },
rangeFilters: { startsAt: { min: "2026-01-01" } },
countFilters: { rsvp: 10 }, // keyed by child collection short name
sort: { recordField: "startsAt", direction: "asc" },
limit: 20,
});
The programmatic shape doesn’t use the URL param names — keys are the underlying field/collection identifiers:
filters/rangeFilters— keyed by the field name from your config (startsAt,subject.uri), not the camelCased URL param.countFilters— keyed by the target collection’s short name for totals, or by the fullnsid#grouptoken for group counts. E.g.,{ rsvp: 10 }for “at least 10 RSVPs total,” or{ "community.lexicon.calendar.rsvp#going": 10 }for “at least 10 going.”sort—{ recordField, direction }for field sorts,{ countType, direction }for count sorts (wherecountTypeis the same collection-short-name ornsid#groupas above).
For count filters / sorts, the HTTP side is nicer than the programmatic side — consider going through createHandler + fetch even for in-process calls if you want the friendly names. Or use createServerClient from @atmo-dev/contrail/server for a typed XRPC client that runs in-process (no fetch roundtrip).
Pagination
?limit=25&cursor=<opaque>
cursor is opaque — pass back whatever listRecords returned in its cursor field. limit is 1–200 (default 50). Cursors embed the sort kind, so a cursor from a sort=startsAt query is ignored by a sort=rsvpsCount query instead of silently returning wrong results.
let cursor: string | undefined;
do {
const page = await contrail.query("event", { limit: 100, cursor });
// process page.records
cursor = page.cursor;
} while (cursor);
Hydration
Each record response is a flat shape:
{
"uri": "at://did:plc:.../community.lexicon.calendar.event/...",
"cid": "...",
"value": { "name": "Rust meetup", "startsAt": "2026-03-16T...", ... },
"rsvpsCount": 42, // from relations
"rsvpsGoingCount": 30,
// relations + references appear here only when hydrated
}
The value field carries the record body — same shape as atproto’s com.atproto.repo.listRecords#record. did, collection, rkey, and time_us are also returned alongside as optional extras.
?hydrateRel=N (relations)
Embeds the latest N child records per group, inline under the parent:
/xrpc/com.example.event.listRecords?hydrateRsvps=5
Returns:
{
"records": [{
"uri": "at://.../event/...",
"value": { "name": "..." },
"rsvpsCount": 42,
"rsvps": {
"going": [ {uri, cid, value}, ... 5 items ],
"interested":[ {uri, cid, value}, ... 5 items ]
}
}]
}
Max 50 per group. For grouped relations you get one array per group value; for ungrouped relations just a flat array.
?hydrateRef=true (references)
Embeds the single referenced parent record — useful for RSVP lists that need to show event details:
/xrpc/com.example.rsvp.listRecords?subjectUri=at://.../event/...&hydrateEvent=true
Each RSVP record in the response gains an event: {uri, cid, value} field.
?profiles=true
Opt in to profile + handle hydration for every DID referenced in the result:
/xrpc/com.example.event.listRecords?profiles=true
Response grows a top-level profiles array, one entry per (DID, configured profile NSID):
{
"records": [...],
"profiles": [
{
"did": "did:plc:alice...",
"handle": "alice.bsky.social",
"uri": "at://did:plc:alice.../app.bsky.actor.profile/self",
"cid": "...",
"collection": "app.bsky.actor.profile",
"rkey": "self",
"value": { /* profile record body */ }
}
]
}
A DID with no profile record (or whose handle resolved but profile didn’t) shows up as a bare { did, handle } entry — uri/cid/value are omitted. With multiple profile NSIDs configured, you’ll see one entry per (DID × NSID) that resolved.
Which profile NSID(s) to hydrate from is configured at the top level of Contrail’s config (profiles, defaults to ["app.bsky.actor.profile"]).
Full-text search
?search=meetup
?search=meetup*
?search="rust meetup"
?search=rust OR typescript
Combinable with every other filter and sort. Backed by SQLite FTS5 (D1) or Postgres tsvector (Postgres adapter). Not available on node:sqlite — that adapter doesn’t ship FTS5.
When searching, results are ranked by relevance by default. Override with an explicit sort param.
Examples
# Upcoming events with 10+ going RSVPs, with RSVP records + profiles
?startsAtMin=2026-03-16&rsvpsGoingCountMin=10&hydrateRsvps=5&profiles=true
# Events for a specific user (by handle — triggers on-demand backfill)
?actor=alice.bsky.social&profiles=true
# RSVPs for one event, with the event record embedded
?subjectUri=at://did:plc:.../event/...&hydrateEvent=true&profiles=true
# Search + filter + sort
?search=meetup&mode=online&sort=startsAt&order=asc