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, and optionally {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=….
Operational status #
GET /status and GET /xrpc/{namespace}.getOverview return the current JSON overview. It includes indexed record totals, the live-ingest cursor and lag, and durable backfill state:
- discovery source progress;
- mutually exclusive account totals for
complete,pending,retrying, andfailed; - known-account completion percentage;
- the same mutually exclusive totals per collection; and
- scheduled/due account retries plus the next retry time.
state is running while a manual or scheduled slice holds the backfill lease. It becomes complete after discovery and the initial pass finish, even when account-level retrying or failed counts are non-zero. pending means no failure has occurred yet, retrying means at least one attempt failed but automatic attempts remain, and failed means the ten-attempt automatic budget is exhausted. An explicit backfill resets that budget. incomplete means discovery or an initial account attempt has not finished.
"Known" is deliberate: while relay discovery is incomplete, Contrail cannot honestly claim how many accounts remain undiscovered. /health remains a lightweight liveness response and does not claim that historical backfill is complete.
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). Field sorts preserve the SQL value type, and records whose field is missing ornullsort last in either direction.
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 complete ordering, including relevance rank for search and URI as the final unique tiebreaker. They also 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