···213213"skipped by <fetchName>" log so authors can debug why the automation isn't
214214firing.
215215216216+A fetch that errors while carrying conditions is also treated as a skip;
217217+see [Interaction with fetch errors](#interaction-with-fetch-errors).
218218+216219## Interaction with fetch errors
217220218221A fetch **error** (bad URI, PDS unreachable, search throws) is distinct from a
219222fetch **not-finding** anything. Errors are collected into the `errors` array
220220-on the `FetchResolution`; the entry is not added to the context, subsequent
221221-fetches that template against it get `undefined`. Dry-run surfaces these as
222222-"Fetch failed: <name>" entries; real runs log them to console but continue.
223223+on the `FetchResolution` and the entry is not added to the context.
224224+225225+Whether the automation fires when a fetch errors depends on whether that
226226+fetch carries **conditions**:
227227+228228+- **Errored fetch with conditions** → `resolveFetches` returns
229229+ `skip: true`, `skippedBy: <step.name>`, `skipCausedByError: true`. The
230230+ automation does not fire. Rationale: a condition is the user declaring
231231+ "only fire if this holds." A transport error means we cannot prove it
232232+ holds, so the safe default for a gated fetch is to not fire. Dry-run
233233+ writes `Skipped: data source "<name>" errored and had conditions` so the
234234+ author can see why.
235235+- **Errored fetch without conditions** (enrichment-only) → the error is
236236+ logged but resolution continues. Subsequent fetches that template against
237237+ the missing entry will see `undefined`. Dry-run still surfaces the error
238238+ per action as `Fetch failed: <name>`.
239239+240240+Real runs always log the underlying error to the console regardless of the
241241+skip path.
223242224243The typical pattern for "only act if X doesn't exist yet" therefore looks like:
225244
+47-3
lib/actions/fetcher.test.ts
···66 fetchRecord: vi.fn(),
77}));
8899+vi.mock("./searcher.js", () => ({
1010+ executeSearch: vi.fn(),
1111+}));
1212+913import { fetchRecord } from "../pds/resolver.js";
1414+import { executeSearch } from "./searcher.js";
1015const mockFetchRecord = vi.mocked(fetchRecord);
1616+const mockExecuteSearch = vi.mocked(executeSearch);
11171218describe("resolveFetches", () => {
1319 const ownerDid = "did:plc:owner";
···15211622 beforeEach(() => {
1723 mockFetchRecord.mockReset();
2424+ mockExecuteSearch.mockReset();
1825 });
19262027 it("resolves a single fetch step", async () => {
···249256 expect(result.context.search1).toBeUndefined();
250257 });
251258252252- it("does not evaluate conditions on a record step that errored", async () => {
259259+ it("skips when an errored record fetch had conditions attached", async () => {
253260 mockFetchRecord.mockRejectedValueOnce(new Error("PDS unreachable"));
254261255262 const result = await resolveFetches(
···257264 {
258265 name: "failing",
259266 uri: "at://did1/col/rk1",
260260- // Even with a condition that would fail on a missing entry,
261261- // the error path takes precedence — no skip is emitted.
262267 conditions: [{ field: "found", operator: "exists", value: "" }],
263268 },
264269 ],
···266271 ownerDid,
267272 );
268273274274+ expect(result.skip).toBe(true);
275275+ expect(result.skippedBy).toBe("failing");
276276+ expect(result.skipCausedByError).toBe(true);
277277+ expect(result.errors).toHaveLength(1);
278278+ });
279279+280280+ it("continues when an errored record fetch has no conditions (enrichment-only)", async () => {
281281+ mockFetchRecord.mockRejectedValueOnce(new Error("PDS unreachable"));
282282+283283+ const result = await resolveFetches(
284284+ [{ name: "failing", uri: "at://did1/col/rk1" }],
285285+ event,
286286+ ownerDid,
287287+ );
288288+269289 expect(result.skip).toBe(false);
290290+ expect(result.errors).toHaveLength(1);
291291+ });
292292+293293+ it("skips when an errored search fetch had conditions attached", async () => {
294294+ mockExecuteSearch.mockRejectedValueOnce(new Error("listRecords timeout"));
295295+296296+ const result = await resolveFetches(
297297+ [
298298+ {
299299+ kind: "search",
300300+ name: "existingMirror",
301301+ repo: "{{self}}",
302302+ collection: "id.sifa.graph.follow",
303303+ where: [{ field: "subject", operator: "eq", value: "x" }],
304304+ conditions: [{ field: "found", operator: "not-exists", value: "" }],
305305+ },
306306+ ],
307307+ event,
308308+ ownerDid,
309309+ );
310310+311311+ expect(result.skip).toBe(true);
312312+ expect(result.skippedBy).toBe("existingMirror");
313313+ expect(result.skipCausedByError).toBe(true);
270314 expect(result.errors).toHaveLength(1);
271315 });
272316 });
+19-4
lib/actions/fetcher.ts
···2828export type FetchResolution = {
2929 context: FetchContext;
3030 errors: Array<{ name: string; error: string }>;
3131- /** True when a per-fetch condition failed. Handler bails before actions. */
3131+ /** True when a per-fetch condition failed, or when a fetch with conditions
3232+ * errored (we can't prove the gate holds, so don't fire). */
3233 skip: boolean;
3333- /** Name of the fetch whose conditions triggered the skip, set alongside
3434- * `skip: true`. Used by dry-run to write an informative delivery log. */
3434+ /** Name of the fetch that triggered the skip, set alongside `skip: true`.
3535+ * Used by dry-run to write an informative delivery log. */
3536 skippedBy?: string;
3737+ /** True when the skip was caused by a fetch error rather than a condition
3838+ * evaluating to false. Lets dry-run surface a distinct reason. */
3939+ skipCausedByError?: boolean;
3640};
37413842/** Resolve all fetch steps, returning available context, any errors, and
···7478 // If any fail, short-circuit before running search steps.
7579 for (const step of recordSteps) {
7680 const entry = context[step.name];
7777- if (!entry) continue; // error path — don't attempt condition gate
8181+ if (!entry) {
8282+ // Error path: if the step declared conditions, treat the error as a
8383+ // gate failure rather than silently proceeding. Enrichment-only
8484+ // fetches (no conditions) keep the log-and-continue behavior.
8585+ if (step.conditions && step.conditions.length > 0) {
8686+ return { context, errors, skip: true, skippedBy: step.name, skipCausedByError: true };
8787+ }
8888+ continue;
8989+ }
7890 if (!evaluateFetchConditions(entry, step.conditions, ownerDid)) {
7991 return { context, errors, skip: true, skippedBy: step.name };
8092 }
···92104 name: step.name,
93105 error: err instanceof Error ? err.message : String(err),
94106 });
107107+ if (step.conditions && step.conditions.length > 0) {
108108+ return { context, errors, skip: true, skippedBy: step.name, skipCausedByError: true };
109109+ }
95110 }
96111 }
97112