···3131 comment?: string;
3232};
33333434-export type Action = WebhookAction | RecordAction | BskyPostAction;
3434+export type PatchRecordAction = {
3535+ $type: "patch-record";
3636+ targetCollection: string;
3737+ baseRecordUri: string; // AT URI template for the record to update
3838+ recordTemplate: string; // JSON template — fields merged on top of base
3939+ comment?: string;
4040+};
4141+4242+export type Action = WebhookAction | RecordAction | BskyPostAction | PatchRecordAction;
4343+4444+/** Action types that produce a record result (uri, cid, rkey) for chaining. */
4545+const RECORD_PRODUCING_TYPES = new Set(["record", "bsky-post", "patch-record"]);
4646+export function isRecordProducingAction(type: string): boolean {
4747+ return RECORD_PRODUCING_TYPES.has(type);
4848+}
35493650export type FetchStep = {
3751 name: string;
+100-10
lib/jetstream/handler.test.ts
···88 executeAction: vi.fn(),
99}));
10101111+vi.mock("@/actions/bsky-post.js", () => ({
1212+ executeBskyPost: vi.fn(),
1313+}));
1414+1515+vi.mock("@/actions/patch-record.js", () => ({
1616+ executePatchRecord: vi.fn(),
1717+}));
1818+1119vi.mock("@/actions/fetcher.js", () => ({
1220 resolveFetches: vi.fn(),
1321}));
···1523import { handleMatchedEvent } from "./handler.js";
1624import { dispatch } from "../webhooks/dispatcher.js";
1725import { executeAction } from "../actions/executor.js";
2626+import { executeBskyPost } from "../actions/bsky-post.js";
2727+import { executePatchRecord } from "../actions/patch-record.js";
1828import { resolveFetches } from "../actions/fetcher.js";
1919-import { makeMatch, makeWebhookAction, makeRecordAction, makeFetchStep } from "../test/fixtures.js";
2929+import {
3030+ makeMatch,
3131+ makeWebhookAction,
3232+ makeRecordAction,
3333+ makeBskyPostAction,
3434+ makeFetchStep,
3535+} from "../test/fixtures.js";
3636+import type { ActionResult } from "../actions/executor.js";
3737+3838+const ok: ActionResult = { statusCode: 200 };
3939+const okWithUri: ActionResult = {
4040+ statusCode: 200,
4141+ uri: "at://did:plc:test/app.bsky.feed.post/abc123",
4242+ cid: "bafytest",
4343+};
20442145const mockDispatch = vi.mocked(dispatch);
2246const mockExecuteAction = vi.mocked(executeAction);
4747+const mockExecuteBskyPost = vi.mocked(executeBskyPost);
4848+const mockExecutePatchRecord = vi.mocked(executePatchRecord);
2349const mockResolveFetches = vi.mocked(resolveFetches);
24502551describe("handleMatchedEvent", () => {
2652 beforeEach(() => {
2727- mockDispatch.mockReset().mockResolvedValue(undefined);
2828- mockExecuteAction.mockReset().mockResolvedValue(undefined);
5353+ mockDispatch.mockReset().mockResolvedValue(ok);
5454+ mockExecuteAction.mockReset().mockResolvedValue(okWithUri);
5555+ mockExecuteBskyPost.mockReset().mockResolvedValue(okWithUri);
5656+ mockExecutePatchRecord.mockReset().mockResolvedValue(okWithUri);
2957 mockResolveFetches.mockReset();
3058 });
3159···48764977 await handleMatchedEvent(match);
50785151- expect(mockExecuteAction).toHaveBeenCalledWith(match, 0, {});
7979+ expect(mockExecuteAction).toHaveBeenCalledOnce();
8080+ expect(mockExecuteAction).toHaveBeenCalledWith(match, 0, expect.any(Object));
5281 expect(mockDispatch).not.toHaveBeenCalled();
5382 });
5483···6796 await handleMatchedEvent(match);
68976998 expect(mockDispatch).toHaveBeenCalledTimes(2);
7070- expect(mockDispatch).toHaveBeenCalledWith(match, 0, {});
7171- expect(mockDispatch).toHaveBeenCalledWith(match, 2, {});
7299 expect(mockExecuteAction).toHaveBeenCalledOnce();
7373- expect(mockExecuteAction).toHaveBeenCalledWith(match, 1, {});
100100+ // Verify correct action indices were called
101101+ expect(mockDispatch.mock.calls[0]![1]).toBe(0);
102102+ expect(mockExecuteAction.mock.calls[0]![1]).toBe(1);
103103+ expect(mockDispatch.mock.calls[1]![1]).toBe(2);
74104 });
7510576106 it("resolves fetches and passes context to all actions", async () => {
···129159 expect(mockDispatch).toHaveBeenCalledWith(match, 0, partialContext);
130160 });
131161132132- it("one action failing does not block others", async () => {
133133- mockDispatch.mockRejectedValueOnce(new Error("webhook failed"));
162162+ it("stops chain when an action fails", async () => {
163163+ mockDispatch.mockResolvedValueOnce({ statusCode: 500, error: "webhook failed" });
134164135165 const match = makeMatch({
136166 automation: {
···141171142172 await handleMatchedEvent(match);
143173144144- // Both actions were called despite the first one rejecting
174174+ // First action failed, second should not run
175175+ expect(mockDispatch).toHaveBeenCalledOnce();
176176+ expect(mockExecuteAction).not.toHaveBeenCalled();
177177+ });
178178+179179+ it("stops chain when an action throws", async () => {
180180+ mockDispatch.mockRejectedValueOnce(new Error("unexpected"));
181181+182182+ const match = makeMatch({
183183+ automation: {
184184+ actions: [makeWebhookAction(), makeRecordAction()],
185185+ fetches: [],
186186+ },
187187+ });
188188+189189+ await handleMatchedEvent(match);
190190+191191+ expect(mockDispatch).toHaveBeenCalledOnce();
192192+ expect(mockExecuteAction).not.toHaveBeenCalled();
193193+ });
194194+195195+ it("accumulates action results into fetchContext", async () => {
196196+ const match = makeMatch({
197197+ automation: {
198198+ actions: [makeRecordAction(), makeBskyPostAction()],
199199+ fetches: [],
200200+ },
201201+ });
202202+203203+ await handleMatchedEvent(match);
204204+205205+ // Second action should receive fetchContext with action0 result
206206+ expect(mockExecuteBskyPost).toHaveBeenCalledWith(
207207+ match,
208208+ 1,
209209+ expect.objectContaining({
210210+ action0: {
211211+ uri: okWithUri.uri,
212212+ cid: okWithUri.cid,
213213+ rkey: "abc123",
214214+ record: {},
215215+ },
216216+ }),
217217+ );
218218+ });
219219+220220+ it("webhooks do not produce action results", async () => {
221221+ const match = makeMatch({
222222+ automation: {
223223+ actions: [makeWebhookAction(), makeRecordAction()],
224224+ fetches: [],
225225+ },
226226+ });
227227+228228+ await handleMatchedEvent(match);
229229+145230 expect(mockDispatch).toHaveBeenCalledOnce();
146231 expect(mockExecuteAction).toHaveBeenCalledOnce();
232232+ // fetchContext should NOT contain action0 (webhook has no uri/cid)
233233+ // but it does contain action1 from the record action (mutated after call)
234234+ // So we verify the record action result is present but webhook result is not
235235+ const finalCtx = mockExecuteAction.mock.calls[0]![2]!;
236236+ expect(finalCtx).not.toHaveProperty("action0");
147237 });
148238});
+46-12
lib/jetstream/handler.ts
···11import { db } from "../db/index.js";
22-import { deliveryLogs, type Action } from "../db/schema.js";
22+import { deliveryLogs, type Action, isRecordProducingAction } from "../db/schema.js";
33import { dispatch, buildPayload } from "../webhooks/dispatcher.js";
44-import { executeAction } from "../actions/executor.js";
44+import { executeAction, type ActionResult } from "../actions/executor.js";
55import { executeBskyPost } from "../actions/bsky-post.js";
66+import { executePatchRecord } from "../actions/patch-record.js";
67import { resolveFetches } from "../actions/fetcher.js";
78import { renderTemplate, renderTextTemplate, type FetchContext } from "../actions/template.js";
89import type { MatchedEvent } from "./consumer.js";
9101011/** Handle a matched Jetstream event: resolve fetches, then dispatch all actions. */
1112export async function handleMatchedEvent(match: MatchedEvent) {
1212- let fetchContext: Record<string, { uri: string; cid: string; record: Record<string, unknown> }> =
1313- {};
1313+ let fetchContext: FetchContext = {};
1414 if (match.automation.fetches.length > 0) {
1515 const result = await resolveFetches(
1616 match.automation.fetches,
···3030 : [];
3131 for (let i = 0; i < match.automation.actions.length; i++) {
3232 const action = match.automation.actions[i]!;
3333- logDryRun(match, i, action, fetchContext, fetchErrors).catch((err) => {
3434- console.error(`Dry-run log error for action ${i}:`, err);
3535- });
3333+ await logDryRun(match, i, action, fetchContext, fetchErrors);
3434+ // Inject synthetic action result so subsequent dry-run actions can reference {{actionN.*}}
3535+ if (isRecordProducingAction(action.$type)) {
3636+ fetchContext[`action${i}`] = {
3737+ uri: `at://dry-run/${action.$type}/placeholder`,
3838+ cid: "dry-run-cid",
3939+ rkey: "placeholder",
4040+ record: {},
4141+ };
4242+ }
3643 }
3744 return;
3845 }
···4451 ? executeBskyPost
4552 : action.$type === "record"
4653 ? executeAction
4747- : dispatch;
4848- handler(match, i, fetchContext).catch((err) => {
4949- console.error(`Action ${i} (${action.$type}) delivery error:`, err);
5050- });
5454+ : action.$type === "patch-record"
5555+ ? executePatchRecord
5656+ : dispatch;
5757+5858+ try {
5959+ const result: ActionResult = await handler(match, i, fetchContext);
6060+6161+ // Accumulate result into fetchContext for downstream actions
6262+ if (result.uri && result.cid) {
6363+ const rkey = result.uri.split("/").pop() ?? "";
6464+ fetchContext[`action${i}`] = { uri: result.uri, cid: result.cid, rkey, record: {} };
6565+ }
6666+6767+ // Fail-fast: stop chain on error
6868+ if (!isActionSuccess(result.statusCode)) {
6969+ console.error(
7070+ `Action ${i} (${action.$type}) failed (${result.statusCode}), stopping chain: ${result.error ?? ""}`,
7171+ );
7272+ break;
7373+ }
7474+ } catch (err) {
7575+ console.error(`Action ${i} (${action.$type}) threw, stopping chain:`, err);
7676+ break;
7777+ }
5178 }
7979+}
8080+8181+function isActionSuccess(code: number): boolean {
8282+ return code >= 200 && code < 400;
5283}
53845485async function logDryRun(
···88119 fetchContext,
89120 match.automation.did,
90121 );
9191- message = `Would create record in ${action.targetCollection}`;
122122+ message =
123123+ action.$type === "patch-record"
124124+ ? `Would patch record in ${action.targetCollection} via ${action.baseRecordUri}`
125125+ : `Would create record in ${action.targetCollection}`;
92126 payload = JSON.stringify(rendered);
93127 } catch (err) {
94128 error = `Template error: ${err instanceof Error ? err.message : String(err)}`;
+1-1
lib/pds/resolver.ts
···88const AT_URI_RE = /^at:\/\/(did:[^/]+)\/([^/]+)\/([^/]+)$/;
991010/** Parse an AT URI into its components. */
1111-function parseAtUri(uri: string): { did: string; collection: string; rkey: string } {
1111+export function parseAtUri(uri: string): { did: string; collection: string; rkey: string } {
1212 const match = uri.match(AT_URI_RE);
1313 if (!match) throw new Error(`Invalid AT URI: ${uri}`);
1414 return { did: match[1]!, collection: match[2]!, rkey: match[3]! };