[READ-ONLY] Mirror of https://github.com/andrioid/n8n-nodes-atproto. atproto node for n8n
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(atproto): Return All pagination + README recipes

Two more UX improvements requested as follow-ups:

1. Return All toggle on List Records and List Blobs
New boolean parameter that paginates internally until the cursor is
exhausted. When on, the Limit / Cursor fields are hidden and a hardcoded
per-page size is used (100 records / 500 blobs). A MAX_PAGES safety cap
(1000) prevents runaway loops.

- List Records (Return All): concatenates records across pages into the
existing single { records: [...] } output shape. Backwards compatible.
- List Blobs (Return All): emits one item per CID across all pages,
no cursor (since pagination is complete).

Without Return All, both ops behave exactly as before.

2. README recipes section
Three worked examples showing how to compose the blob ops into real
workflows:
- Compose a multi-image Bluesky post (the main thing the new ops unlock)
- Round-trip a blob (download \u2192 transform \u2192 re-upload)
- List every blob in your repo (audits / migrations)

Each recipe is laid out as an ASCII node graph with key parameters
inline, plus 'why this works' / 'tip' callouts for the non-obvious bits.

Tests: extends listBlobs tests with a manual-pagination case to lock in
the cursor-passthrough behavior. 216 tests pass; lint clean.

+211 -30
+77
README.md
··· 61 61 - **Blob fields** — the field label tells you to provide a binary property name. Use an HTTP Request or Read Binary File node upstream to attach the file 62 62 - **Nested objects** — sub-refs beyond the first level show as JSON fields with a template of the expected structure 63 63 64 + ## Recipes 65 + 66 + Worked examples of common workflows. Each one is a chain of nodes — names in **bold**, key parameters inline. 67 + 68 + ### Compose a multi-image Bluesky post 69 + 70 + `app.bsky.feed.post` supports up to 4 images, but the blobs live nested inside `embed.images[].image`, so the record's own lexicon doesn't know they're blobs. Use the standalone **Upload Blob** op to upload each image, then compose the embed by hand. 71 + 72 + ``` 73 + [HTTP Request: image 1]─┐ 74 + [HTTP Request: image 2]─┼─▶[AT Protocol: Blob → Upload] (run once per image, via Loop or three parallel branches) 75 + [HTTP Request: image 3]─┘ 76 + 77 + 78 + [Aggregate / Set: collect blob refs into `images` array] 79 + 80 + 81 + [AT Protocol: Record → Create] 82 + Collection: app.bsky.feed.post 83 + recordData: { 84 + "text": "three pictures", 85 + "embed": { 86 + "$type": "app.bsky.embed.images", 87 + "images": [ 88 + { "alt": "...", "image": {{ $node["Upload 1"].json.blob }} }, 89 + { "alt": "...", "image": {{ $node["Upload 2"].json.blob }} }, 90 + { "alt": "...", "image": {{ $node["Upload 3"].json.blob }} } 91 + ] 92 + } 93 + } 94 + ``` 95 + 96 + **Why this works:** `Upload Blob` outputs the canonical BlobRef shape (`{ $type, ref, mimeType, size }`) under `blob`, which is exactly what `embed.images[].image` expects. n8n's expression engine drops the object straight in. 97 + 98 + **Convenience fields:** the upload also exposes `cid` / `mimeType` / `size` at the top level, so `{{ $json.cid }}` works without drilling into `blob.ref.$link`. 99 + 100 + ### Round-trip a blob (download, transform, re-upload) 101 + 102 + Mirror a blob from another user's repo into your own. Useful for archival, format conversion, or re-hosting. 103 + 104 + ``` 105 + [AT Protocol: Record → Get] ← fetch a post that contains an image 106 + Collection: app.bsky.feed.post 107 + Repo: alice.bsky.social 108 + rkey: 3jzfc... 109 + 110 + 111 + [Set: extract blob ref] ← {{ $json.value.embed.images[0].image }} 112 + 113 + 114 + [AT Protocol: Blob → Download] ← paste the BlobRef into Blob Reference; 115 + (no Repo needed if pasting a CDN URL, Repo = alice.bsky.social for at:// refs 116 + otherwise paste the source handle) 117 + 118 + 119 + [Edit Image / Sharp / Compress] ← optional: re-encode, resize, watermark 120 + 121 + 122 + [AT Protocol: Blob → Upload] ← now lives in YOUR repo as a fresh blob 123 + ``` 124 + 125 + **Tip:** `Download Blob` accepts whichever input is most convenient — a bare CID, the BlobRef JSON pasted from a previous op, or a `https://cdn.bsky.app/img/.../<did>/<cid>@jpeg` URL. CDN URLs also fill in the Repo automatically. 126 + 127 + ### List every blob in your repo 128 + 129 + For audits, migrations, or finding orphaned blobs not referenced by any record. 130 + 131 + ``` 132 + [AT Protocol: Blob → List] 133 + Return All: ✅ ← paginates internally; emits one item per CID 134 + 135 + 136 + [Filter / Set / Aggregate to taste] 137 + ``` 138 + 139 + Without `Return All`, the node emits one item per CID for the current page and attaches the next-page `cursor` to each item, so manual pagination is also possible. 140 + 64 141 ## Credentials 65 142 66 143 | Field | Description |
+108 -30
src/nodes/Atproto/Atproto.node.ts
··· 10 10 import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; 11 11 import type { Agent } from '@atproto/api'; 12 12 13 + import type { ListRecordsResult } from './operations'; 13 14 import { 14 15 createRecord, 15 16 deleteRecord, ··· 22 23 uploadBlob, 23 24 applyConstValues, 24 25 } from './operations'; 26 + 27 + // Pagination guardrails for Return All. The page sizes match what each 28 + // XRPC endpoint accepts as a sensible upper bound; MAX_PAGES caps total 29 + // requests so a mis-configured workflow can't loop forever. 30 + const PAGE_SIZE_RECORDS = 100; 31 + const PAGE_SIZE_BLOBS = 500; 32 + const MAX_PAGES = 1000; 25 33 import { createAgent, extractCollectionNsid, searchCollections } from './shared'; 26 34 import { generateTid } from './tid'; 27 35 import { resolveLexiconSchema } from './lexicon'; ··· 564 572 }, 565 573 566 574 // ------------------------------------------------------------------ 567 - // Limit (List Records / List Blobs) 575 + // Return All (List Records / List Blobs) 576 + // ------------------------------------------------------------------ 577 + { 578 + displayName: 'Return All', 579 + name: 'returnAll', 580 + type: 'boolean', 581 + default: false, 582 + description: 583 + 'Whether to paginate through all results automatically. When off, returns one page using Limit / Cursor.', 584 + displayOptions: { 585 + show: { 586 + operation: ['listRecords', 'listBlobs'], 587 + }, 588 + }, 589 + }, 590 + 591 + // ------------------------------------------------------------------ 592 + // Limit (only when Return All is off) 568 593 // ------------------------------------------------------------------ 569 594 { 570 595 displayName: 'Limit', ··· 577 602 displayOptions: { 578 603 show: { 579 604 operation: ['listRecords', 'listBlobs'], 605 + returnAll: [false], 580 606 }, 581 607 }, 582 608 default: 50, ··· 584 610 }, 585 611 586 612 // ------------------------------------------------------------------ 587 - // Cursor (List Records / List Blobs — optional) 613 + // Cursor (only when Return All is off) 588 614 // ------------------------------------------------------------------ 589 615 { 590 616 displayName: 'Cursor', ··· 597 623 displayOptions: { 598 624 show: { 599 625 operation: ['listRecords', 'listBlobs'], 626 + returnAll: [false], 600 627 }, 601 628 }, 602 629 default: '', ··· 822 849 } 823 850 824 851 case 'listRecords': { 825 - const limit = this.getNodeParameter('limit', i) as number; 826 - const cursor = this.getNodeParameter('cursor', i) as string; 852 + const returnAll = this.getNodeParameter( 853 + 'returnAll', 854 + i, 855 + false, 856 + ) as boolean; 827 857 const repo = this.getNodeParameter('repo', i) as string; 828 858 829 - const res = await listRecords(agent, { 830 - collection, 831 - limit, 832 - ...(cursor ? { cursor } : {}), 833 - ...(repo ? { repo } : {}), 834 - }); 835 - result = res as unknown as IDataObject; 859 + if (returnAll) { 860 + // Paginate internally. Concatenate all records across pages. 861 + const allRecords: ListRecordsResult['records'] = []; 862 + let pageCursor: string | undefined = undefined; 863 + for (let page = 0; page < MAX_PAGES; page++) { 864 + const pageRes = await listRecords(agent, { 865 + collection, 866 + limit: PAGE_SIZE_RECORDS, 867 + ...(pageCursor ? { cursor: pageCursor } : {}), 868 + ...(repo ? { repo } : {}), 869 + }); 870 + allRecords.push(...pageRes.records); 871 + if (!pageRes.cursor) break; 872 + pageCursor = pageRes.cursor; 873 + } 874 + result = { records: allRecords } as unknown as IDataObject; 875 + } else { 876 + const limit = this.getNodeParameter('limit', i) as number; 877 + const cursor = this.getNodeParameter('cursor', i) as string; 878 + const res = await listRecords(agent, { 879 + collection, 880 + limit, 881 + ...(cursor ? { cursor } : {}), 882 + ...(repo ? { repo } : {}), 883 + }); 884 + result = res as unknown as IDataObject; 885 + } 836 886 break; 837 887 } 838 888 ··· 952 1002 } 953 1003 954 1004 case 'listBlobs': { 955 - const limit = this.getNodeParameter('limit', i) as number; 956 - const cursor = this.getNodeParameter('cursor', i) as string; 1005 + const returnAll = this.getNodeParameter( 1006 + 'returnAll', 1007 + i, 1008 + false, 1009 + ) as boolean; 957 1010 const repoInput = this.getNodeParameter('repo', i) as string; 958 1011 const listOpts = this.getNodeParameter('options', i, {}) as { 959 1012 since?: string; ··· 963 1016 ? await resolveActorToDid(agent, repoInput) 964 1017 : agent.did ?? undefined; 965 1018 966 - const res = await listBlobs(agent, { 967 - ...(did ? { did } : {}), 968 - limit, 969 - ...(cursor ? { cursor } : {}), 970 - ...(listOpts.since ? { since: listOpts.since } : {}), 971 - }); 972 - 973 - // Emit one output item per CID so downstream Filter/Loop/Set 974 - // nodes can iterate naturally. Cursor is attached to every item 975 - // so any downstream node can drive the next page. 976 - for (const blobCid of res.cids) { 977 - returnData.push({ 978 - json: { 979 - cid: blobCid, 1019 + if (returnAll) { 1020 + // Paginate internally; emit one item per CID with no cursor. 1021 + let pageCursor: string | undefined = undefined; 1022 + for (let page = 0; page < MAX_PAGES; page++) { 1023 + const pageRes = await listBlobs(agent, { 980 1024 ...(did ? { did } : {}), 981 - ...(res.cursor ? { cursor: res.cursor } : {}), 982 - }, 983 - pairedItem: { item: i }, 1025 + limit: PAGE_SIZE_BLOBS, 1026 + ...(pageCursor ? { cursor: pageCursor } : {}), 1027 + ...(listOpts.since ? { since: listOpts.since } : {}), 1028 + }); 1029 + for (const blobCid of pageRes.cids) { 1030 + returnData.push({ 1031 + json: { 1032 + cid: blobCid, 1033 + ...(did ? { did } : {}), 1034 + }, 1035 + pairedItem: { item: i }, 1036 + }); 1037 + } 1038 + if (!pageRes.cursor) break; 1039 + pageCursor = pageRes.cursor; 1040 + } 1041 + } else { 1042 + const limit = this.getNodeParameter('limit', i) as number; 1043 + const cursor = this.getNodeParameter('cursor', i) as string; 1044 + const res = await listBlobs(agent, { 1045 + ...(did ? { did } : {}), 1046 + limit, 1047 + ...(cursor ? { cursor } : {}), 1048 + ...(listOpts.since ? { since: listOpts.since } : {}), 984 1049 }); 1050 + 1051 + // One item per CID; cursor attached to each so any downstream 1052 + // node can drive the next page. 1053 + for (const blobCid of res.cids) { 1054 + returnData.push({ 1055 + json: { 1056 + cid: blobCid, 1057 + ...(did ? { did } : {}), 1058 + ...(res.cursor ? { cursor: res.cursor } : {}), 1059 + }, 1060 + pairedItem: { item: i }, 1061 + }); 1062 + } 985 1063 } 986 1064 continue; 987 1065 }
+26
tests/blobOps.test.ts
··· 213 213 expect(result.cids).toEqual([CID_1]); 214 214 expect(result.cursor).toBeUndefined(); 215 215 }); 216 + 217 + it('supports manual pagination across pages via cursor passthrough', async () => { 218 + // First page: full result with cursor; second page: tail with no cursor. 219 + let calls = 0; 220 + setMockResponse('com.atproto.sync.listBlobs', () => { 221 + calls += 1; 222 + if (calls === 1) { 223 + return { cids: [CID_1, CID_2], cursor: 'page-2' }; 224 + } 225 + return { cids: [CID_3] }; 226 + }); 227 + 228 + const page1 = await listBlobs(agent, { limit: 2 }); 229 + expect(page1.cids).toEqual([CID_1, CID_2]); 230 + expect(page1.cursor).toBe('page-2'); 231 + 232 + const page2 = await listBlobs(agent, { limit: 2, cursor: page1.cursor }); 233 + expect(page2.cids).toEqual([CID_3]); 234 + expect(page2.cursor).toBeUndefined(); 235 + 236 + // Verify the cursor was actually sent on the second request. 237 + const cursorReq = interceptedRequests.filter((r) => 238 + r.url.includes('com.atproto.sync.listBlobs'), 239 + ); 240 + expect(cursorReq[1].url).toContain('cursor=page-2'); 241 + }); 216 242 }); 217 243 218 244 // ---------------------------------------------------------------------------