···670670 <span class={s.hint}>
671671 DID of the account to follow on {meta.appName}. Supports {"{{placeholders}}"} like{" "}
672672 {"{{event.did}}"} or {"{{event.commit.record.subject}}"}.
673673+ <br />
674674+ Automatically checks that the subject has a {meta.appName} profile and that you don't
675675+ already follow them. No extra conditions needed.
673676 </span>
674677 </div>
675678 </>
+10-13
docs/data-source-fetches.md
···5151 (`at://did/collection/rkey`). Non-AT-URI → the fetch errors with a log entry.
52523. The URI is fetched via `fetchRecord` ([lib/pds/resolver.ts](../lib/pds/resolver.ts)),
5353 which resolves the DID to its PDS and calls `com.atproto.repo.getRecord`.
5454-4. A 404 writes `found: false`; any other failure is treated as an error.
5454+4. A 404, _or_ a 400 with XRPC error body `{"error":"RecordNotFound"}`
5555+ (which is what the `com.atproto.repo.getRecord` lexicon actually returns
5656+ for missing records on most PDSes), writes `found: false`. Any other
5757+ failure is treated as an error.
55585659Record fetches are **independent of each other** and run in parallel via
5760`Promise.all`. Their `conditions` are evaluated once all record fetches have
···7679 "name": "existingMirror",
7780 "repo": "{{self}}",
7881 "collection": "id.sifa.graph.follow",
7979- "where": [
8080- { "field": "subject", "operator": "eq", "value": "{{event.commit.record.subject}}" }
8181- ],
8282+ "where": [{ "field": "subject", "operator": "eq", "value": "{{event.commit.record.subject}}" }],
8283 "limit": 1,
8383- "conditions": [
8484- { "field": "found", "operator": "not-exists", "value": "" }
8585- ]
8484+ "conditions": [{ "field": "found", "operator": "not-exists", "value": "" }]
8685}
8786```
8887···9493- `where` — list of equality clauses. Currently only `operator: "eq"` is
9594 supported. Multiple clauses are ANDed.
9695- `limit` — max number of matches to accept. Defaults to 1. The current
9797- executor always returns the *first* match as the context entry; `limit` just
9696+ executor always returns the _first_ match as the context entry; `limit` just
9897 controls how many matches the executor is willing to find before stopping.
9998- `conditions` — per-fetch conditions evaluated after the search resolves.
10099 Typically `found` + `exists` / `not-exists` to gate on presence.
···1611604. If the PDS returns a `cursor`, continue; otherwise stop.
162161163162If the scan completes without finding a match, the entry is `notFoundEntry()`.
164164-If the 100-page cap is hit *and* the cursor would continue, a warning is
163163+If the 100-page cap is hit _and_ the cursor would continue, a warning is
165164logged and the entry is still `notFoundEntry()` — we intentionally treat an
166165exhausted scan as "not found" rather than erroring, because the usual caller
167166is a `not-exists` gate and false negatives are preferable to hard failures.
···198197199198## Per-fetch conditions
200199201201-Both fetch kinds support a `conditions` array. These run *after* the fetch
200200+Both fetch kinds support a `conditions` array. These run _after_ the fetch
202201resolves and are evaluated against the fetch's own entry — paths are
203202entry-scoped, not event-scoped:
204203···232231 "collection": "id.sifa.graph.follow",
233232 "where": [{ "field": "subject", "operator": "eq", "value": "{{event.commit.record.subject}}" }],
234233 "limit": 1,
235235- "conditions": [
236236- { "field": "found", "operator": "not-exists", "value": "" }
237237- ]
234234+ "conditions": [{ "field": "found", "operator": "not-exists", "value": "" }]
238235}
239236```
240237
+10-13
docs/wanted-dids.md
···91919292### When you'd use both
93939494-Nothing stops you from setting `wantedDids` *and* adding `event.did` conditions
9494+Nothing stops you from setting `wantedDids` _and_ adding `event.did` conditions
9595on top, and there's a real use case for it: `wantedDids` narrows the firehose
9696to a set of accounts cheaply, and the condition layer then applies additional
9797constraints (record fields, subject DIDs, etc.) that Jetstream can't express.
···118118([lib/jetstream/consumer.ts](../lib/jetstream/consumer.ts)):
119119120120```ts
121121-if (
122122- nsidRequiresWantedDids(row.lexicon, config.nsidRequireDids) &&
123123- resolvedDids.length === 0
124124-) {
121121+if (nsidRequiresWantedDids(row.lexicon, config.nsidRequireDids) && resolvedDids.length === 0) {
125122 console.warn(
126123 `Jetstream: skipping ${row.uri} — ${row.lexicon} requires wantedDids but none are set`,
127124 );
···165162166163## Rules of thumb
167164168168-| Situation | Use |
169169-| ---------------------------------------------------------- | ----------------- |
170170-| Automation listens to an `app.bsky.*` collection | `wantedDids` |
171171-| Owner-only automation on a custom lexicon | Either; `wantedDids` preferred |
172172-| "Anyone posting about topic X on `run.airglow.*`" | Condition on record fields, no DID filter |
173173-| Stable list of ≤ a few hundred DIDs | `wantedDids` |
174174-| Rapidly changing DID allowlist | `event.did` condition |
175175-| Filter by DID **and** by record-shape in the same rule | `wantedDids` + conditions |
165165+| Situation | Use |
166166+| ------------------------------------------------------ | ----------------------------------------- |
167167+| Automation listens to an `app.bsky.*` collection | `wantedDids` |
168168+| Owner-only automation on a custom lexicon | Either; `wantedDids` preferred |
169169+| "Anyone posting about topic X on `run.airglow.*`" | Condition on record fields, no DID filter |
170170+| Stable list of ≤ a few hundred DIDs | `wantedDids` |
171171+| Rapidly changing DID allowlist | `event.did` condition |
172172+| Filter by DID **and** by record-shape in the same rule | `wantedDids` + conditions |
176173177174When in doubt: if the NSID is in `NSID_REQUIRES_DIDS`, you don't get a choice
178175— the manager will skip the automation until `wantedDids` is populated.
···88export type ActionResult = {
99 statusCode: number;
1010 error?: string;
1111+ /** Human-readable, non-error summary. Used by skip-style results (e.g. the
1212+ * follow action's built-in safety checks) that want a readable trace in the
1313+ * delivery log without marking the fire as a failure. */
1414+ message?: string;
1115 /** AT URI of the created/updated record (record-producing actions only). */
1216 uri?: string;
1317 /** CID of the created/updated record (record-producing actions only). */
···11-import { type FollowAction, FOLLOW_TARGET_COLLECTION } from "../db/schema.js";
11+import {
22+ type FollowAction,
33+ type FetchStepSearch,
44+ FOLLOW_TARGET_COLLECTION,
55+ FOLLOW_TARGET_PROFILE,
66+} from "../db/schema.js";
27import { createArbitraryRecord } from "../automations/pds.js";
88+import { fetchRecord } from "../pds/resolver.js";
99+import { executeSearch } from "./searcher.js";
310import { renderTextTemplate, type FetchContext } from "./template.js";
411import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "./delivery.js";
512import { DID_RE } from "./validation.js";
1313+import { FOLLOW_TARGET_META } from "../automations/follow-targets.js";
614import type { ActionResult } from "./executor.js";
715import type { MatchedEvent } from "../jetstream/consumer.js";
8161717+/** HTTP status used to signal a skipped fire (no profile, already following).
1818+ * Counts as success for chain-continuation (no fail-fast) and retry (no 5xx),
1919+ * but is filtered out of rate-limit counts — a skipped follow did zero PDS
2020+ * work and shouldn't burn the user's budget. */
2121+const SKIP_STATUS = 204;
2222+2323+async function checkProfileExists(
2424+ action: FollowAction,
2525+ subject: string,
2626+): Promise<ActionResult | null> {
2727+ const profile = FOLLOW_TARGET_PROFILE[action.target];
2828+ const profileUri = `at://${subject}/${profile.collection}/${profile.rkey}`;
2929+ try {
3030+ const result = await fetchRecord(profileUri);
3131+ if (!result.found) {
3232+ const appName = FOLLOW_TARGET_META[action.target].appName;
3333+ return {
3434+ statusCode: SKIP_STATUS,
3535+ message: `Skipped: no ${appName} profile for ${subject}`,
3636+ };
3737+ }
3838+ } catch (err) {
3939+ // Transient lookup failure (DID not resolvable, PDS unreachable) is not
4040+ // the same signal as 404. Fall through and let the follow attempt proceed;
4141+ // if it genuinely can't succeed, the PDS write will surface the failure
4242+ // the normal way.
4343+ console.warn(
4444+ `follow: profile pre-check failed for ${subject} on ${action.target}: ${err instanceof Error ? err.message : String(err)}`,
4545+ );
4646+ }
4747+ return null;
4848+}
4949+5050+/**
5151+ * "Does the automation owner already follow the subject on the target graph?"
5252+ *
5353+ * Reuses `executeSearch`, which transparently picks the Bluesky appview
5454+ * fast-path for `target: "bluesky"` and falls back to a paginated
5555+ * `listRecords` scan on the owner's repo for Tangled/Sifa. The scan cost
5656+ * matters there: for an owner with ~10k existing follows on those targets the
5757+ * worst-case is tens of HTTP round-trips per fire, because AT Proto has no
5858+ * server-side field index. Good enough for the MVP; revisit with a local
5959+ * mirror-links table if it becomes a bottleneck.
6060+ *
6161+ * Not atomic with the subsequent write: two events racing for the same
6262+ * subject can both observe `found: false` before either's follow record
6363+ * lands, so a fast double-trigger can still produce duplicate rows.
6464+ * Acceptable because Bluesky's appview collapses duplicate follows by
6565+ * subject in the public graph; the extra row is cosmetic until cleaned up.
6666+ */
6767+async function checkNotAlreadyFollowing(
6868+ match: MatchedEvent,
6969+ action: FollowAction,
7070+ subject: string,
7171+): Promise<ActionResult | null> {
7272+ const collection = FOLLOW_TARGET_COLLECTION[action.target];
7373+ const synthetic: FetchStepSearch = {
7474+ kind: "search",
7575+ name: "__follow_preflight_already_follows",
7676+ repo: match.automation.did,
7777+ collection,
7878+ where: [{ field: "subject", operator: "eq", value: subject }],
7979+ limit: 1,
8080+ };
8181+ try {
8282+ const entry = await executeSearch(synthetic, match.event, match.automation.did, {});
8383+ if (entry.found) {
8484+ const appName = FOLLOW_TARGET_META[action.target].appName;
8585+ return {
8686+ statusCode: SKIP_STATUS,
8787+ message: `Skipped: already following ${subject} on ${appName}`,
8888+ };
8989+ }
9090+ } catch (err) {
9191+ // Same rationale as the profile pre-check: a transient search failure
9292+ // shouldn't block the follow. Worst case is a duplicate record, which
9393+ // the Bluesky appview collapses anyway — storage carries two rows until
9494+ // one is cleaned up, but the public graph is still correct.
9595+ console.warn(
9696+ `follow: already-follows pre-check failed for ${subject} on ${action.target}: ${err instanceof Error ? err.message : String(err)}`,
9797+ );
9898+ }
9999+ return null;
100100+}
101101+9102async function execute(
10103 match: MatchedEvent,
11104 action: FollowAction,
···31124 return { statusCode: 400, error: `subject is not a valid DID: "${subject}"` };
32125 }
33126127127+ // Built-in safety checks — run sequentially so users don't have to add them
128128+ // manually as fetches + conditions. Either check resolving to "skip" writes
129129+ // a 204 log entry and short-circuits before the PDS write.
130130+ const profileSkip = await checkProfileExists(action, subject);
131131+ if (profileSkip) return profileSkip;
132132+133133+ const dupSkip = await checkNotAlreadyFollowing(match, action, subject);
134134+ if (dupSkip) return dupSkip;
135135+34136 const collection = FOLLOW_TARGET_COLLECTION[action.target];
35137 const record: Record<string, unknown> = {
36138 subject,
···78180 result.statusCode,
79181 result.error ?? null,
80182 retryIndex + 2,
183183+ result.message ?? null,
81184 );
8218583186 if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) {
···107210 result.statusCode,
108211 result.error ?? null,
109212 1,
213213+ result.message ?? null,
110214 );
111215112216 if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) {
+2-2
lib/actions/searcher.test.ts
···122122123123 it("stops at a safety cap and returns found:false on an exhausted scan", async () => {
124124 mockResolveDid.mockResolvedValueOnce("https://pds.example.com");
125125- // Always return a non-matching page with a cursor → trigger the 20-page cap.
125125+ // Always return a non-matching page with a cursor → trigger the 100-page cap.
126126 mockFetch.mockResolvedValue({
127127 ok: true,
128128 json: async () => ({
···140140 const result = await executeSearch(makeSearchStep(), event, ownerDid, {});
141141142142 expect(result.found).toBe(false);
143143- expect(mockFetch).toHaveBeenCalledTimes(20);
143143+ expect(mockFetch).toHaveBeenCalledTimes(100);
144144 });
145145146146 it("renders templates in repo and where values", async () => {
+6-45
lib/automations/action-catalogue.ts
···88 UserPlus,
99 Webhook,
1010} from "../../app/icons.js";
1111-import type { FollowTarget } from "../db/schema.js";
1111+import { FOLLOW_TARGET_META, type ColorKey } from "./follow-targets.js";
1212+1313+// Re-exported so existing consumers can keep importing from action-catalogue.
1414+// The data itself lives in follow-targets.ts (no icon / JSX dependencies) so
1515+// the backend action pipeline can read `appName` without pulling in UI code.
1616+export { FOLLOW_TARGET_META, type ColorKey };
12171318export type AddableActionId =
1419 | "webhook"
···2025 | "follow-tangled"
2126 | "follow-sifa";
22272323-/** Keys that map to a CSS selector in action-header.css.ts. Keep in sync
2424- * with the `&[data-cat="..."]` selectors there; a typo silently loses the
2525- * accent otherwise. */
2626-export type ColorKey = "webhook" | "bluesky" | "pds" | "apps" | "sifa" | "tangled";
2727-2828type ActionInfo = {
2929 label: string;
3030 icon: (typeof ACTION_CATALOGUE)[number]["actions"][number]["icon"];
···3535 colorKey?: ColorKey;
3636 /** Domain used to render the per-app favicon next to the icon (bookmark, follow). */
3737 faviconDomain?: string;
3838-};
3939-4040-/** Per-target metadata for follow actions. Shared between catalogue, editor,
4141- * card target-icon, and ActionHeader. */
4242-export const FOLLOW_TARGET_META: Record<
4343- FollowTarget,
4444- {
4545- catId: "bluesky" | "apps";
4646- colorKey: ColorKey;
4747- appName: string;
4848- label: string;
4949- faviconDomain: string;
5050- description: string;
5151- }
5252-> = {
5353- bluesky: {
5454- catId: "bluesky",
5555- colorKey: "bluesky",
5656- appName: "Bluesky",
5757- label: "Follow on Bluesky",
5858- faviconDomain: "bsky.app",
5959- description: "Follow someone on Bluesky",
6060- },
6161- tangled: {
6262- catId: "apps",
6363- colorKey: "tangled",
6464- appName: "Tangled",
6565- label: "Follow on Tangled",
6666- faviconDomain: "tangled.sh",
6767- description: "Follow someone on Tangled",
6868- },
6969- sifa: {
7070- catId: "apps",
7171- colorKey: "sifa",
7272- appName: "Sifa",
7373- label: "Follow on Sifa",
7474- faviconDomain: "sifa.id",
7575- description: "Follow someone on Sifa",
7676- },
7738};
78397940export const ACTION_CATALOGUE = [
+47
lib/automations/follow-targets.ts
···11+import type { FollowTarget } from "../db/schema.js";
22+33+/** Keys that map to a CSS selector in action-header.css.ts. Keep in sync
44+ * with the `&[data-cat="..."]` selectors there; a typo silently loses the
55+ * accent otherwise. */
66+export type ColorKey = "webhook" | "bluesky" | "pds" | "apps" | "sifa" | "tangled";
77+88+/** Per-target metadata for follow actions. Lives in a pure-data module (no JSX
99+ * / icon imports) so backend code paths like `executeFollow` and the dry-run
1010+ * logger can read `appName` without pulling in UI components. The action
1111+ * catalogue in `action-catalogue.ts` re-exports this for UI consumers. */
1212+export const FOLLOW_TARGET_META: Record<
1313+ FollowTarget,
1414+ {
1515+ catId: "bluesky" | "apps";
1616+ colorKey: ColorKey;
1717+ appName: string;
1818+ label: string;
1919+ faviconDomain: string;
2020+ description: string;
2121+ }
2222+> = {
2323+ bluesky: {
2424+ catId: "bluesky",
2525+ colorKey: "bluesky",
2626+ appName: "Bluesky",
2727+ label: "Follow on Bluesky",
2828+ faviconDomain: "bsky.app",
2929+ description: "Follow someone on Bluesky",
3030+ },
3131+ tangled: {
3232+ catId: "apps",
3333+ colorKey: "tangled",
3434+ appName: "Tangled",
3535+ label: "Follow on Tangled",
3636+ faviconDomain: "tangled.sh",
3737+ description: "Follow someone on Tangled",
3838+ },
3939+ sifa: {
4040+ catId: "apps",
4141+ colorKey: "sifa",
4242+ appName: "Sifa",
4343+ label: "Follow on Sifa",
4444+ faviconDomain: "sifa.id",
4545+ description: "Follow someone on Sifa",
4646+ },
4747+};
+9
lib/db/schema.ts
···7474 sifa: "id.sifa.graph.follow",
7575};
76767777+/** Profile record each follow target expects to exist before a follow is
7878+ * meaningful. Used by the follow action's built-in "profile exists" pre-flight
7979+ * check so users don't have to hand-write a record fetch + condition for it. */
8080+export const FOLLOW_TARGET_PROFILE: Record<FollowTarget, { collection: string; rkey: string }> = {
8181+ bluesky: { collection: "app.bsky.actor.profile", rkey: "self" },
8282+ tangled: { collection: "sh.tangled.actor.profile", rkey: "self" },
8383+ sifa: { collection: "id.sifa.profile.self", rkey: "self" },
8484+};
8585+7786/** Action types that produce a record result (uri, cid, rkey) for chaining. */
7887const RECORD_PRODUCING_TYPES = new Set([
7988 "record",
+26
lib/jetstream/handler.test.ts
···5454 makeRecordAction,
5555 makeBskyPostAction,
5656 makeFetchStep,
5757+ makeFollowAction,
5758} from "../test/fixtures.js";
5859import type { ActionResult } from "../actions/executor.js";
5960···212213 message: expect.stringContaining("existingMirror"),
213214 }),
214215 );
216216+ });
217217+218218+ it("dry-run follow log advertises the built-in safety checks", async () => {
219219+ // The real `executeFollow` runs profile + already-follows pre-flight checks
220220+ // inside the action handler. Dry-run skips those (would burn PDS calls for
221221+ // a preview) but the dry-run log message should tell the author the real
222222+ // run will skip cleanly on both edges.
223223+ mockInsert.mockClear();
224224+ mockInsertValues.mockClear();
225225+226226+ const match = makeMatch({
227227+ automation: {
228228+ actions: [makeFollowAction({ target: "bluesky", subject: "did:plc:someone" })],
229229+ fetches: [],
230230+ dryRun: true,
231231+ },
232232+ });
233233+234234+ await handleMatchedEvent(match);
235235+236236+ expect(mockInsertValues).toHaveBeenCalledTimes(1);
237237+ const logged = mockInsertValues.mock.calls[0]![0] as { message: string; dryRun: boolean };
238238+ expect(logged.dryRun).toBe(true);
239239+ expect(logged.message).toContain("Would follow did:plc:someone on bluesky");
240240+ expect(logged.message).toContain("will skip if no Bluesky profile exists or already following");
215241 });
216242217243 it("skips fetch resolution when no fetch steps", async () => {
+6-1
lib/jetstream/handler.ts
···77import { executeBookmark } from "../actions/bookmark.js";
88import { executeFollow } from "../actions/follow.js";
99import { FOLLOW_TARGET_COLLECTION } from "../db/schema.js";
1010+import { FOLLOW_TARGET_META } from "../automations/follow-targets.js";
1011import { resolveFetches } from "../actions/fetcher.js";
1112import { renderTemplate, renderTextTemplate, type FetchContext } from "../actions/template.js";
1213import { parseAtUri } from "../pds/resolver.js";
···162163 await renderTextTemplate(action.subject, match.event, fetchContext, match.automation)
163164 ).trim();
164165 const collection = FOLLOW_TARGET_COLLECTION[action.target];
165165- message = `Would follow ${subject || "(empty)"} on ${action.target}`;
166166+ const appName = FOLLOW_TARGET_META[action.target].appName;
167167+ // The built-in safety checks live inside executeFollow and aren't run in
168168+ // dry-run (keeps the preview cheap). Advertise their presence in the
169169+ // message so authors know the real run will skip cleanly on both edges.
170170+ message = `Would follow ${subject || "(empty)"} on ${action.target} (will skip if no ${appName} profile exists or already following)`;
166171 payload = JSON.stringify({ collection, subject });
167172 } catch (err) {
168173 error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
···11-import { and, eq, gte } from "drizzle-orm";
11+import { and, eq, gte, ne, or, isNull } from "drizzle-orm";
22import { db } from "../db/index.js";
33import { automations, deliveryLogs } from "../db/schema.js";
44···48484949/** Timestamps of recent (non dry-run) log rows. Honours `rateLimitResetAt` so
5050 * a manual re-enable after an auto-disable starts the counters from zero
5151- * instead of immediately re-tripping on the same logs. */
5151+ * instead of immediately re-tripping on the same logs.
5252+ *
5353+ * `statusCode = 204` is excluded: those rows are emitted by action-level
5454+ * safety checks (e.g. the follow action's "already following" / "no profile"
5555+ * skips) that did zero PDS work and shouldn't burn the user's budget. `IS
5656+ * NULL` is kept to be forward-compatible with any future non-dry-run log
5757+ * that omits the status. */
5258async function loadRecentTimestamps(
5359 automation: RateLimitAutomation,
5460 now: number,
···6470 eq(deliveryLogs.automationUri, automation.uri),
6571 eq(deliveryLogs.dryRun, false),
6672 gte(deliveryLogs.createdAt, cutoff),
7373+ or(ne(deliveryLogs.statusCode, 204), isNull(deliveryLogs.statusCode)),
6774 ),
6875 );
6976 return rows.map((r) => r.createdAt.getTime());
···100100 }
101101102102 if (!res.ok) {
103103+ // AT Proto's `com.atproto.repo.getRecord` lexicon returns HTTP 400 with an
104104+ // XRPC error body `{ "error": "RecordNotFound" }` when the record is
105105+ // missing (rather than 404, which some PDSes also emit and we handle
106106+ // above). Normalize the 400 variant to the same not-found entry so
107107+ // per-fetch `found exists`/`not-exists` conditions can gate on it. Other
108108+ // 4xx/5xx still throw — those are genuine errors the caller should see.
109109+ if (res.status === 400) {
110110+ try {
111111+ const body = (await res.json()) as { error?: string };
112112+ if (body.error === "RecordNotFound") {
113113+ return { found: false, uri: atUri, cid: "", did, collection, rkey, record: {} };
114114+ }
115115+ } catch {
116116+ // Body wasn't JSON, or didn't include `error` — fall through to throw.
117117+ }
118118+ }
103119 throw new Error(`PDS getRecord failed (${res.status}) for ${atUri}`);
104120 }
105121