Data source fetches #
An automation can declare a list of fetches that run after the trigger
event matches but before actions fire. Each fetch resolves to a named entry in
the fetchContext, which action templates can reference via
{{fetchName.record.*}} and per-fetch conditions can gate on.
There are two kinds today, discriminated by the kind field on the stored
FetchStep (lib/db/schema.ts):
kind: "record"— resolve a specific AT URI to its record. The default ifkindis absent (legacy rows).kind: "search"— find a record in a repo by field equality. Used to answer "does a record already exist that matches X?" before acting.
Both kinds return a FetchContextEntry with a found flag; neither throws on
"not found" — that signal is observed via exists / not-exists conditions
on the fetch.
type FetchContextEntry = {
found: boolean;
uri: string;
cid: string;
did?: string;
collection?: string;
rkey?: string;
record: Record<string, unknown>;
};
Record fetches #
The simple shape. One HTTP request, O(1), no pagination.
{
"kind": "record",
"name": "parentPost",
"uri": "at://{{event.commit.record.reply.parent.uri}}"
}
Resolution #
- The
uritemplate is rendered against the event + upstream context + owner DID.{{self}},{{event.*}}, and{{otherFetch.*}}all work. - The rendered string is validated against the AT URI shape
(
at://did/collection/rkey). Non-AT-URI → the fetch errors with a log entry. - The URI is fetched via
fetchRecord(lib/pds/resolver.ts), which resolves the DID to its PDS and callscom.atproto.repo.getRecord. - A 404, or a 400 with XRPC error body
{"error":"RecordNotFound"}(which is what thecom.atproto.repo.getRecordlexicon actually returns for missing records on most PDSes), writesfound: false. Any other failure is treated as an error.
Record fetches are independent of each other and run in parallel via
Promise.all. Their conditions are evaluated once all record fetches have
resolved (and before any search fetches begin).
When to use #
- Enriching the event with the parent post, the quoted record, the followed subject's profile, etc.
- Any case where you already know the exact AT URI to look up.
Search fetches #
The richer shape. Answers "is there a record in this repo/collection whose field X equals Y?" Used primarily to prevent duplicates in mirror-style automations — e.g. "only create a Sifa follow if no Sifa follow for this subject already exists."
{
"kind": "search",
"name": "existingMirror",
"repo": "{{self}}",
"collection": "id.sifa.graph.follow",
"where": [{ "field": "subject", "operator": "eq", "value": "{{event.commit.record.subject}}" }],
"limit": 1,
"conditions": [{ "field": "found", "operator": "not-exists", "value": "" }]
}
Inputs #
repo— template that must resolve to a DID. Typically{{self}}but may reference an event field or an upstream fetch.collection— literal NSID (not a template). The collection to scan.where— list of equality clauses. Currently onlyoperator: "eq"is supported. Multiple clauses are ANDed.limit— max number of matches to accept. Defaults to 1. The current executor always returns the first match as the context entry;limitjust controls how many matches the executor is willing to find before stopping.conditions— per-fetch conditions evaluated after the search resolves. Typicallyfound+exists/not-existsto gate on presence.
Execution strategy #
Search doesn't have a single primitive in AT Proto; there's no server-side equivalent of "get a record by field equality." The executor picks one of two strategies.
1. Appview fast-path (Bluesky follows only) #
The specific case of "does actor follow subject on Bluesky" is answered in
O(1) by the Bluesky appview's app.bsky.graph.getRelationships endpoint. The
executor detects this shape:
if (step.collection === "app.bsky.graph.follow") {
const subjectClause = hasOnlyClause(step, "subject", "eq");
if (subjectClause) {
// → single appview request, parses the `following` AT URI out of the response
}
}
Trigger conditions:
collection === "app.bsky.graph.follow"- Exactly one
whereclause - That clause is
subject eq <DID>
Any other shape on app.bsky.graph.follow falls through to the generic path.
If the appview returns no following URI for the subject, the entry is
notFoundEntry() (i.e. found: false). This is the correct answer — the user
doesn't follow the subject — and it's distinct from an appview transport
failure, which returns null and falls through to listRecords.
2. Generic listRecords scan #
The fallback. Resolves the repo's PDS endpoint, paginates
com.atproto.repo.listRecords, and filters results client-side against the
where clauses.
LIST_RECORDS_PAGE_SIZE = 100 // capped by the listRecords lexicon
MAX_LIST_RECORDS_PAGES = 100 // → 10k records scanned at most
HTTP_TIMEOUT_MS = 10s // per page
The page size is a hard ceiling: com.atproto.repo.listRecords declares
limit as minimum: 1, maximum: 100, default: 50, so spec-compliant PDSs
will reject or clamp anything higher. To scan further we can only add pages.
Per page:
- Fetch up to 100 records via
listRecords. - For each record, check every
whereclause via dotted-path read (same machinery the condition layer uses,readPathon the record value). - Collect matches until
limitis reached; the first match is what ends up in the context entry. - If the PDS returns a
cursor, continue; otherwise stop.
If the scan completes without finding a match, the entry is notFoundEntry().
If the 100-page cap is hit and the cursor would continue, a warning is
logged and the entry is still notFoundEntry() — we intentionally treat an
exhausted scan as "not found" rather than erroring, because the usual caller
is a not-exists gate and false negatives are preferable to hard failures.
This is a tradeoff worth understanding: for collections large enough to
exceed 10,000 records, the "does X exist?" answer may be incorrect. For the
motivating use case — follow lists — this covers the vast majority of
accounts; heavy-follower accounts (>10k followees) may see stale-not-found
results until the cap is raised or replaced with an indexed data source.
Cost #
- Appview path: one HTTP request, a few KB, returned in tens of ms.
- listRecords path: up to 100 HTTP requests (each up to 100 records). A small repo resolves in one page; a repo on the order of 10k records is typically a few seconds when the PDS is healthy. Each page is 10s-capped individually, so the theoretical worst case is 1000s — and because searches run sequentially in the fetcher, that serializes with any other searches in the same automation. In practice that worst case only shows up when a PDS is degraded or throttling; budget for a few seconds, plan for a few more.
Searches are sequential in the fetcher — unlike record fetches which run in
parallel — because a search's repo or where clauses can template against
upstream fetch results. Running them serially keeps that dependency model
straightforward for the MVP.
The where clause model is deliberately narrow #
The current shape (equality only, always AND) is intentional. AT Proto has no
server-side query language for record fields, so every operator added to
where must be evaluated in listRecords-scan code. Equality covers the
anti-duplicate use case that motivated search; richer operators would encourage
queries that are quietly expensive on large repos.
Per-fetch conditions #
Both fetch kinds support a conditions array. These run after the fetch
resolves and are evaluated against the fetch's own entry — paths are
entry-scoped, not event-scoped:
field: "found"+exists/not-existstests the boolean flag directly (special-cased, because stringifyingfalsewould otherwise read as non-empty).field: "record.subject"walks into the fetched record.field: "uri"/field: "cid"test the top-level entry fields.
If any condition on any fetch fails, resolveFetches returns skip: true and
the handler short-circuits before actions. This is treated as normal filtering
— no delivery log entry, no error. In dry-run mode the handler does write a
"skipped by " log so authors can debug why the automation isn't
firing.
A fetch that errors while carrying conditions is also treated as a skip; see Interaction with fetch errors.
Retries on transient failures #
Every outbound request made by the fetchers (PDS getRecord, listRecords,
appview getRelationships) goes through fetchWithRetry in
lib/pds/fetch-with-retry.ts. A single request
is transparently retried on:
- Thrown errors from
fetch(DNS failure, connection reset, TLS error). AbortSignal.timeoutfiring (per-attempt timeout).- Any
5xxresponse from the server.
Retry schedule: 1s + 3s (two retries). 4xx responses are stable answers and
are never retried; in particular, 404 and the XRPC RecordNotFound body on
HTTP 400 still resolve to found: false on the first attempt.
Retries run inline in the handler, so a slow retry does delay the event's processing. Two retries with this schedule add at most ~4s on top of the per-attempt timeouts (currently 10s each). After retries are exhausted, the error surfaces to the caller as described below.
Interaction with fetch errors #
A fetch error (bad URI, PDS unreachable after retries, search throws) is
distinct from a fetch not-finding anything. Errors are collected into the
errors array on the FetchResolution and the entry is not added to the
context.
Whether the automation fires when a fetch errors depends on whether that fetch carries conditions:
- Errored fetch with conditions →
resolveFetchesreturnsskip: true,skippedBy: <step.name>,skipCausedByError: true. The automation does not fire. Rationale: a condition is the user declaring "only fire if this holds." A transport error means we cannot prove it holds, so the safe default for a gated fetch is to not fire. Dry-run writesSkipped: data source "<name>" errored and had conditionsso the author can see why. - Errored fetch without conditions (enrichment-only) → the error is
logged but resolution continues. Subsequent fetches that template against
the missing entry will see
undefined. Dry-run still surfaces the error per action asFetch failed: <name>.
Real runs always log the underlying error to the console regardless of the skip path.
The typical pattern for "only act if X doesn't exist yet" therefore looks like:
{
"kind": "search",
"name": "existingMirror",
"repo": "{{self}}",
"collection": "id.sifa.graph.follow",
"where": [{ "field": "subject", "operator": "eq", "value": "{{event.commit.record.subject}}" }],
"limit": 1,
"conditions": [{ "field": "found", "operator": "not-exists", "value": "" }]
}
- Search resolves →
found: false(nothing matched). - Condition
found not-existspasses. - Handler proceeds to actions.
If the same subject already exists:
- Search resolves →
found: truewith the existing record. - Condition
found not-existsfails. skip: truebubbles up, actions never run, no log in production.