@fedify/backfill 2.4.2-dev.2362 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,6 +65,14 @@ for await (
65
65
  The seed object itself is not yielded. If it appears in the discovered
66
66
  collection, it is skipped by ID.
67
67
 
68
+ Context and `replies` collections support inline items and pagination through
69
+ `first` and successive `next` links. A collection page used as the starting
70
+ collection also follows `next`. Pages are loaded as needed, and traversal
71
+ stops on repeated page links. Pagination does not increase reply-tree depth.
72
+ If a page cannot be loaded, items already yielded are retained and later
73
+ configured strategies can continue unless cancellation or traversal limits
74
+ stop them.
75
+
68
76
  Configured strategies run in order. They share `maxItems`, `maxRequests`,
69
77
  abort state, and object ID deduplication; if two strategies discover the same
70
78
  object, the first strategy keeps its `BackfillItem` metadata.
@@ -133,7 +141,7 @@ All configured strategies share the same traversal controls:
133
141
  - `maxItems` limits the number of yielded objects. Skipped duplicates do
134
142
  not count.
135
143
  - `maxRequests` limits calls to `documentLoader`. Embedded objects and
136
- collections do not count.
144
+ collections, including embedded pages, do not count.
137
145
  - `maxDepth` limits reply-tree traversal and defaults to 10. It does not
138
146
  limit context collection items.
139
147
  - `interval` adds a delay between loader requests. Its callback receives
@@ -145,7 +153,11 @@ An `interval` string requires the global `Temporal` API or a polyfill.
145
153
 
146
154
  If the seed has no context, or its context resolves to a non-collection,
147
155
  context strategies yield nothing. Loader failures are skipped unless
148
- traversal is aborted.
156
+ traversal is aborted. If the context collection cannot be loaded, later
157
+ configured strategies can still run. Failed loads consume a request, and
158
+ later strategies can use embedded data even when the request budget is
159
+ exhausted. Configuration errors, such as an invalid `interval`, still
160
+ propagate.
149
161
 
150
162
  Dereferenced documents are cached in memory for one `backfill()` traversal.
151
163
  Applications that need persistent or shared caching can provide it through
@@ -114,7 +114,7 @@ function currentContextGroupHasAuto(strategies) {
114
114
  async function* getContextStrategyItems(context, note, strategies, options, budget, seenIds) {
115
115
  const contextId = note.contextIds[0];
116
116
  if (contextId == null) return;
117
- const collection = await loadObject(context, contextId, options, budget);
117
+ const collection = await loadObject(context, contextId, options, budget, { skipLoaderErrors: true });
118
118
  if (!isCollection(collection)) return;
119
119
  for await (const object of getCollectionItems(context, collection, options, budget, seenIds)) for (const strategy of strategies) for await (const item of getContextBackfillItems(context, object, strategy, options, budget)) yield {
120
120
  object: item.object,
@@ -281,12 +281,49 @@ async function* getContextBackfillItems(context, object, strategy, options, budg
281
281
  }
282
282
  }
283
283
  async function* getCollectionItems(context, collection, options, budget, skipIds) {
284
- yield* collection.getItems({
284
+ const itemOptions = {
285
285
  documentLoader: async (url) => {
286
286
  return await loadCollectionItemDocument(context, url, options, budget, skipIds);
287
287
  },
288
288
  crossOrigin: "trust"
289
- });
289
+ };
290
+ const pageOptions = {
291
+ documentLoader: async (url) => {
292
+ const object = await loadObject(context, new URL(url), options, budget, { throwOnBudgetExceeded: true });
293
+ if (object == null) throw new Error(`Collection page not found: ${url}`);
294
+ return {
295
+ contextUrl: null,
296
+ documentUrl: url,
297
+ document: await object.toJsonLd()
298
+ };
299
+ },
300
+ crossOrigin: "trust"
301
+ };
302
+ const visitedIds = /* @__PURE__ */ new Set();
303
+ const visitedPages = /* @__PURE__ */ new WeakSet();
304
+ let current = collection;
305
+ while (current != null) {
306
+ budget.signal?.throwIfAborted();
307
+ if (visitedPages.has(current)) return;
308
+ if (current.id != null) {
309
+ if (visitedIds.has(current.id.href)) return;
310
+ visitedIds.add(current.id.href);
311
+ }
312
+ visitedPages.add(current);
313
+ yield* current.getItems(itemOptions);
314
+ const page = current instanceof _fedify_vocab.CollectionPage || current instanceof _fedify_vocab.OrderedCollectionPage ? current : null;
315
+ const pageId = page == null ? current.firstId : page.nextId;
316
+ if (pageId != null && visitedIds.has(pageId.href)) return;
317
+ try {
318
+ budget.signal?.throwIfAborted();
319
+ current = page == null ? await current.getFirst(pageOptions) : await page.getNext(pageOptions);
320
+ } catch (error) {
321
+ if (error instanceof MaxRequestsExceeded) throw error;
322
+ budget.signal?.throwIfAborted();
323
+ return;
324
+ }
325
+ if (pageId != null && pageId.href !== current?.id?.href) visitedIds.add(pageId.href);
326
+ }
290
327
  }
291
328
  async function getCreateActivityObject(context, object, options, budget) {
292
329
  if (!(object instanceof _fedify_vocab.Create)) return null;
@@ -308,7 +345,7 @@ async function loadCollectionItemDocument(context, url, options, budget, skipIds
308
345
  try {
309
346
  const iri = new URL(url);
310
347
  if (skipIds?.has(iri.href)) return skippedCollectionItemDocument(url);
311
- object = await loadObject(context, iri, options, budget, true);
348
+ object = await loadObject(context, iri, options, budget, { throwOnBudgetExceeded: true });
312
349
  } catch (error) {
313
350
  if (error instanceof MaxRequestsExceeded) throw error;
314
351
  budget.signal?.throwIfAborted();
@@ -331,7 +368,7 @@ function skippedCollectionItemDocument(url) {
331
368
  }
332
369
  };
333
370
  }
334
- async function loadObject(context, iri, options, budget, throwOnBudgetExceeded = false) {
371
+ async function loadObject(context, iri, options, budget, { throwOnBudgetExceeded = false, skipLoaderErrors = false } = {}) {
335
372
  budget.signal?.throwIfAborted();
336
373
  const cacheKey = iri.href;
337
374
  const cached = budget.documents.get(cacheKey);
@@ -343,12 +380,17 @@ async function loadObject(context, iri, options, budget, throwOnBudgetExceeded =
343
380
  await waitForInterval(options, budget);
344
381
  budget.signal?.throwIfAborted();
345
382
  budget.requestCount++;
346
- const document = context.documentLoader(iri, { signal: budget.signal });
347
- budget.documents.set(cacheKey, document);
383
+ let document;
348
384
  try {
385
+ document = context.documentLoader(iri, { signal: budget.signal });
386
+ budget.documents.set(cacheKey, document);
349
387
  return await document;
350
388
  } catch (error) {
351
389
  if (budget.documents.get(cacheKey) === document) budget.documents.delete(cacheKey);
390
+ if (skipLoaderErrors) {
391
+ budget.signal?.throwIfAborted();
392
+ return null;
393
+ }
352
394
  throw error;
353
395
  }
354
396
  }
@@ -1560,4 +1602,292 @@ async function collect(context, note, options = {}) {
1560
1602
  (0, node_assert_strict.deepStrictEqual)(iterations, [0, 1]);
1561
1603
  });
1562
1604
  });
1605
+ (0, node_test.describe)("collection pagination", () => {
1606
+ const iri = (name) => new URL(`https://example.com/${name}`);
1607
+ for (const ordered of [false, true]) for (const replies of [false, true]) for (const embedded of [false, true]) (0, node_test.default)(`${ordered ? "ordered" : "unordered"} ${replies ? "replies" : "context"} with ${embedded ? "embedded" : "linked"} pages`, async () => {
1608
+ const CollectionType = ordered ? _fedify_vocab.OrderedCollection : _fedify_vocab.Collection;
1609
+ const PageType = ordered ? _fedify_vocab.OrderedCollectionPage : _fedify_vocab.CollectionPage;
1610
+ const a = new _fedify_vocab.Note({ id: iri("a") });
1611
+ const b = new _fedify_vocab.Note({ id: iri("b") });
1612
+ const second = new PageType({
1613
+ id: iri("page-2"),
1614
+ items: [b]
1615
+ });
1616
+ const first = new PageType({
1617
+ id: iri("page-1"),
1618
+ items: [a],
1619
+ next: embedded ? second : second.id
1620
+ });
1621
+ const collection = new CollectionType({
1622
+ id: iri("thread"),
1623
+ first: embedded ? first : first.id
1624
+ });
1625
+ const seed = new _fedify_vocab.Note({
1626
+ id: iri("seed"),
1627
+ contexts: replies ? [] : [iri("thread")],
1628
+ replies: replies ? collection : null
1629
+ });
1630
+ const documents = /* @__PURE__ */ new Map([
1631
+ [iri("thread").href, collection],
1632
+ [iri("page-1").href, first],
1633
+ [iri("page-2").href, second]
1634
+ ]);
1635
+ const calls = [];
1636
+ const items = await collect({ documentLoader: (url) => {
1637
+ calls.push(url.href);
1638
+ return Promise.resolve(documents.get(url.href) ?? null);
1639
+ } }, seed, {
1640
+ strategies: replies ? ["reply-tree"] : ["context-auto"],
1641
+ maxDepth: 1,
1642
+ maxRequests: embedded ? replies ? 0 : 1 : 3
1643
+ });
1644
+ (0, node_assert_strict.deepStrictEqual)(items.map((item) => item.id?.href), [a.id?.href, b.id?.href]);
1645
+ (0, node_assert_strict.deepStrictEqual)(items.map((item) => item.depth), replies ? [1, 1] : [0, 0]);
1646
+ (0, node_assert_strict.strictEqual)(calls.length, embedded ? replies ? 0 : 1 : replies ? 2 : 3);
1647
+ });
1648
+ (0, node_test.default)("starting page follows next and stops before loading a repeated page", async () => {
1649
+ const first = new _fedify_vocab.OrderedCollectionPage({
1650
+ id: iri("page-1"),
1651
+ items: [new _fedify_vocab.Note({ id: iri("a") })],
1652
+ next: iri("page-2")
1653
+ });
1654
+ const second = new _fedify_vocab.OrderedCollectionPage({
1655
+ id: iri("page-2"),
1656
+ items: [new _fedify_vocab.Note({ id: iri("b") })],
1657
+ next: first.id
1658
+ });
1659
+ const calls = [];
1660
+ (0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: (url) => {
1661
+ calls.push(url.href);
1662
+ return Promise.resolve(url.href === first.id?.href ? first : second);
1663
+ } }, new _fedify_vocab.Note({ contexts: [first.id] }))).map((item) => item.id?.href), [iri("a").href, iri("b").href]);
1664
+ (0, node_assert_strict.deepStrictEqual)(calls, [first.id?.href, second.id?.href]);
1665
+ });
1666
+ for (const limit of ["maxItems", "maxRequests"]) (0, node_test.default)(`${limit} stops between pages`, async () => {
1667
+ const first = new _fedify_vocab.CollectionPage({
1668
+ id: iri("page-1"),
1669
+ items: [new _fedify_vocab.Note({ id: iri("a") })],
1670
+ next: iri("page-2")
1671
+ });
1672
+ const calls = [];
1673
+ (0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: (url) => {
1674
+ calls.push(url.href);
1675
+ return Promise.resolve(first);
1676
+ } }, new _fedify_vocab.Note({
1677
+ contexts: [first.id],
1678
+ replies: new _fedify_vocab.Collection({ items: [new _fedify_vocab.Note({ id: iri("fallback") })] })
1679
+ }), {
1680
+ [limit]: 1,
1681
+ strategies: ["context-auto", "reply-tree"]
1682
+ })).map((item) => item.id?.href), [iri("a").href]);
1683
+ (0, node_assert_strict.deepStrictEqual)(calls, [first.id?.href]);
1684
+ });
1685
+ for (const failure of [
1686
+ "missing",
1687
+ "throw",
1688
+ "invalid"
1689
+ ]) (0, node_test.default)(`${failure} page retains items and allows the next strategy`, async () => {
1690
+ const seed = new _fedify_vocab.Note({
1691
+ contexts: [iri("thread")],
1692
+ replies: new _fedify_vocab.Collection({ items: [new _fedify_vocab.Note({ id: iri("reply") })] })
1693
+ });
1694
+ const first = new _fedify_vocab.CollectionPage({
1695
+ items: [new _fedify_vocab.Note({ id: iri("a") })],
1696
+ next: iri("broken")
1697
+ });
1698
+ (0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: (url) => {
1699
+ if (url.href === iri("thread").href) return Promise.resolve(new _fedify_vocab.Collection({ first }));
1700
+ if (failure === "throw") throw new Error("page failed");
1701
+ return Promise.resolve(failure === "missing" ? null : new _fedify_vocab.Note({}));
1702
+ } }, seed, { strategies: ["context-auto", "reply-tree"] })).map((item) => item.id?.href), [iri("a").href, iri("reply").href]);
1703
+ });
1704
+ (0, node_test.default)("inline items precede page items", async () => {
1705
+ (0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: () => Promise.resolve(new _fedify_vocab.Collection({
1706
+ items: [new _fedify_vocab.Note({ id: iri("inline") })],
1707
+ first: new _fedify_vocab.CollectionPage({ items: [new _fedify_vocab.Note({ id: iri("paged") })] })
1708
+ })) }, new _fedify_vocab.Note({ contexts: [iri("thread")] }))).map((item) => item.id?.href), [iri("inline").href, iri("paged").href]);
1709
+ });
1710
+ (0, node_test.default)("cancellation during a page load prevents later strategies", async () => {
1711
+ const controller = new AbortController();
1712
+ const reason = /* @__PURE__ */ new Error("cancelled");
1713
+ await (0, node_assert_strict.rejects)(collect({ documentLoader: (url, options) => {
1714
+ (0, node_assert_strict.strictEqual)(options?.signal, controller.signal);
1715
+ if (url.href === iri("thread").href) return Promise.resolve(new _fedify_vocab.Collection({ first: iri("page") }));
1716
+ controller.abort(reason);
1717
+ throw reason;
1718
+ } }, new _fedify_vocab.Note({
1719
+ contexts: [iri("thread")],
1720
+ replies: new _fedify_vocab.Collection({ items: [new _fedify_vocab.Note({ id: iri("fallback") })] })
1721
+ }), {
1722
+ signal: controller.signal,
1723
+ strategies: ["context-auto", "reply-tree"]
1724
+ }), (error) => error === reason);
1725
+ });
1726
+ });
1727
+ (0, node_test.default)("page loads share the cache and interval across strategies", async () => {
1728
+ const iri = (name) => new URL(`https://example.com/${name}`);
1729
+ const collection = new _fedify_vocab.Collection({ first: iri("page") });
1730
+ const page = new _fedify_vocab.CollectionPage({ items: [new _fedify_vocab.Note({ id: iri("a") })] });
1731
+ const seed = new _fedify_vocab.Note({
1732
+ contexts: [iri("thread")],
1733
+ replies: new _fedify_vocab.Collection({ first: iri("page") })
1734
+ });
1735
+ const calls = [];
1736
+ const intervals = [];
1737
+ (0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: (url) => {
1738
+ calls.push(url.href);
1739
+ return Promise.resolve(url.href === iri("thread").href ? collection : page);
1740
+ } }, seed, {
1741
+ strategies: ["context-auto", "reply-tree"],
1742
+ maxRequests: 2,
1743
+ interval: (count) => {
1744
+ intervals.push(count);
1745
+ return { milliseconds: 0 };
1746
+ }
1747
+ })).map((item) => item.id?.href), [iri("a").href]);
1748
+ (0, node_assert_strict.deepStrictEqual)(calls, [iri("thread").href, iri("page").href]);
1749
+ (0, node_assert_strict.deepStrictEqual)(intervals, [0, 1]);
1750
+ });
1751
+ (0, node_test.default)("embedded pages without IDs follow next without loader calls", async () => {
1752
+ const a = new _fedify_vocab.Note({ id: new URL("https://example.com/a") });
1753
+ const b = new _fedify_vocab.Note({ id: new URL("https://example.com/b") });
1754
+ (0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: () => {
1755
+ throw new Error("embedded pages must not be fetched");
1756
+ } }, new _fedify_vocab.Note({ replies: new _fedify_vocab.Collection({ first: new _fedify_vocab.CollectionPage({
1757
+ items: [a],
1758
+ next: new _fedify_vocab.CollectionPage({ items: [b] })
1759
+ }) }) }), {
1760
+ strategies: ["reply-tree"],
1761
+ maxRequests: 0,
1762
+ maxDepth: 1
1763
+ })).map((item) => item.id?.href), [a.id?.href, b.id?.href]);
1764
+ });
1765
+ (0, node_test.describe)("context loader failures", () => {
1766
+ const contextId = new URL("https://example.com/thread");
1767
+ const parent = new _fedify_vocab.Note({ id: new URL("https://example.com/parent") });
1768
+ for (const synchronous of [false, true]) {
1769
+ const failure = synchronous ? "synchronous throw" : "promise rejection";
1770
+ const fail = () => {
1771
+ const error = /* @__PURE__ */ new Error("Context collection unavailable");
1772
+ if (synchronous) throw error;
1773
+ return Promise.reject(error);
1774
+ };
1775
+ (0, node_test.default)(`${failure} allows reply-tree to yield an embedded parent`, async () => {
1776
+ const seed = new _fedify_vocab.Note({
1777
+ contexts: [contextId],
1778
+ replyTarget: parent
1779
+ });
1780
+ let requests = 0;
1781
+ const items = await collect({ documentLoader: () => {
1782
+ requests++;
1783
+ return fail();
1784
+ } }, seed, {
1785
+ strategies: ["context-auto", "reply-tree"],
1786
+ maxRequests: 1
1787
+ });
1788
+ (0, node_assert_strict.deepStrictEqual)(items.map((item) => item.object), [parent]);
1789
+ (0, node_assert_strict.strictEqual)(items[0].strategy, "reply-tree");
1790
+ (0, node_assert_strict.strictEqual)(requests, 1);
1791
+ });
1792
+ (0, node_test.default)(`${failure} finishes the default strategy without items`, async () => {
1793
+ const seed = new _fedify_vocab.Note({ contexts: [contextId] });
1794
+ (0, node_assert_strict.deepStrictEqual)(await collect({ documentLoader: fail }, seed), []);
1795
+ });
1796
+ (0, node_test.default)(`${failure} consumes the request budget`, async () => {
1797
+ const seed = new _fedify_vocab.Note({
1798
+ contexts: [contextId],
1799
+ replyTarget: parent.id
1800
+ });
1801
+ const requests = [];
1802
+ (0, node_assert_strict.deepStrictEqual)(await collect({ documentLoader: (url) => {
1803
+ requests.push(url.href);
1804
+ if (url.href === contextId.href) return fail();
1805
+ return Promise.resolve(parent);
1806
+ } }, seed, {
1807
+ strategies: ["context-auto", "reply-tree"],
1808
+ maxRequests: 1
1809
+ }), []);
1810
+ (0, node_assert_strict.deepStrictEqual)(requests, [contextId.href]);
1811
+ });
1812
+ (0, node_test.default)(`${failure} propagates the cancellation reason`, async () => {
1813
+ const controller = new AbortController();
1814
+ const reason = /* @__PURE__ */ new Error("Stop backfill");
1815
+ const seed = new _fedify_vocab.Note({
1816
+ contexts: [contextId],
1817
+ replyTarget: parent
1818
+ });
1819
+ const yielded = [];
1820
+ await (0, node_assert_strict.rejects)(async () => {
1821
+ for await (const item of backfill({ documentLoader: (_url, options) => {
1822
+ (0, node_assert_strict.strictEqual)(options?.signal, controller.signal);
1823
+ controller.abort(reason);
1824
+ return fail();
1825
+ } }, seed, {
1826
+ strategies: ["context-auto", "reply-tree"],
1827
+ signal: controller.signal
1828
+ })) yielded.push(item);
1829
+ }, (error) => error === reason);
1830
+ (0, node_assert_strict.deepStrictEqual)(yielded, []);
1831
+ });
1832
+ }
1833
+ (0, node_test.default)("a failed context load is not cached for later reply-tree loads", async () => {
1834
+ const seed = new _fedify_vocab.Note({
1835
+ contexts: [contextId],
1836
+ replyTarget: contextId
1837
+ });
1838
+ let requests = 0;
1839
+ (0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: (url) => {
1840
+ (0, node_assert_strict.strictEqual)(url.href, contextId.href);
1841
+ requests++;
1842
+ if (requests === 1) return Promise.reject(/* @__PURE__ */ new Error("Temporary failure"));
1843
+ return Promise.resolve(parent);
1844
+ } }, seed, {
1845
+ strategies: ["context-auto", "reply-tree"],
1846
+ maxRequests: 2
1847
+ })).map((item) => item.object.id?.href), [parent.id?.href]);
1848
+ (0, node_assert_strict.strictEqual)(requests, 2);
1849
+ });
1850
+ (0, node_test.default)("an exhausted budget still allows embedded reply-tree data", async () => {
1851
+ (0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: () => {
1852
+ throw new Error("No requests allowed");
1853
+ } }, new _fedify_vocab.Note({
1854
+ contexts: [contextId],
1855
+ replyTarget: parent
1856
+ }), {
1857
+ strategies: ["context-auto", "reply-tree"],
1858
+ maxRequests: 0
1859
+ })).map((item) => item.object), [parent]);
1860
+ });
1861
+ (0, node_test.default)("interval callback errors propagate before the loader is called", async () => {
1862
+ const reason = /* @__PURE__ */ new Error("Invalid interval configuration");
1863
+ let requests = 0;
1864
+ await (0, node_assert_strict.rejects)(collect({ documentLoader: () => {
1865
+ requests++;
1866
+ return Promise.resolve(null);
1867
+ } }, new _fedify_vocab.Note({
1868
+ contexts: [contextId],
1869
+ replyTarget: parent
1870
+ }), {
1871
+ strategies: ["context-auto", "reply-tree"],
1872
+ interval: () => {
1873
+ throw reason;
1874
+ }
1875
+ }), (error) => error === reason);
1876
+ (0, node_assert_strict.strictEqual)(requests, 0);
1877
+ });
1878
+ (0, node_test.default)("invalid interval strings still propagate", async () => {
1879
+ let requests = 0;
1880
+ await (0, node_assert_strict.rejects)(collect({ documentLoader: () => {
1881
+ requests++;
1882
+ return Promise.resolve(null);
1883
+ } }, new _fedify_vocab.Note({
1884
+ contexts: [contextId],
1885
+ replyTarget: parent
1886
+ }), {
1887
+ strategies: ["context-auto", "reply-tree"],
1888
+ interval: "not a duration"
1889
+ }));
1890
+ (0, node_assert_strict.strictEqual)(requests, 0);
1891
+ });
1892
+ });
1563
1893
  //#endregion
@@ -91,7 +91,7 @@ function currentContextGroupHasAuto(strategies) {
91
91
  async function* getContextStrategyItems(context, note, strategies, options, budget, seenIds) {
92
92
  const contextId = note.contextIds[0];
93
93
  if (contextId == null) return;
94
- const collection = await loadObject(context, contextId, options, budget);
94
+ const collection = await loadObject(context, contextId, options, budget, { skipLoaderErrors: true });
95
95
  if (!isCollection(collection)) return;
96
96
  for await (const object of getCollectionItems(context, collection, options, budget, seenIds)) for (const strategy of strategies) for await (const item of getContextBackfillItems(context, object, strategy, options, budget)) yield {
97
97
  object: item.object,
@@ -258,12 +258,49 @@ async function* getContextBackfillItems(context, object, strategy, options, budg
258
258
  }
259
259
  }
260
260
  async function* getCollectionItems(context, collection, options, budget, skipIds) {
261
- yield* collection.getItems({
261
+ const itemOptions = {
262
262
  documentLoader: async (url) => {
263
263
  return await loadCollectionItemDocument(context, url, options, budget, skipIds);
264
264
  },
265
265
  crossOrigin: "trust"
266
- });
266
+ };
267
+ const pageOptions = {
268
+ documentLoader: async (url) => {
269
+ const object = await loadObject(context, new URL(url), options, budget, { throwOnBudgetExceeded: true });
270
+ if (object == null) throw new Error(`Collection page not found: ${url}`);
271
+ return {
272
+ contextUrl: null,
273
+ documentUrl: url,
274
+ document: await object.toJsonLd()
275
+ };
276
+ },
277
+ crossOrigin: "trust"
278
+ };
279
+ const visitedIds = /* @__PURE__ */ new Set();
280
+ const visitedPages = /* @__PURE__ */ new WeakSet();
281
+ let current = collection;
282
+ while (current != null) {
283
+ budget.signal?.throwIfAborted();
284
+ if (visitedPages.has(current)) return;
285
+ if (current.id != null) {
286
+ if (visitedIds.has(current.id.href)) return;
287
+ visitedIds.add(current.id.href);
288
+ }
289
+ visitedPages.add(current);
290
+ yield* current.getItems(itemOptions);
291
+ const page = current instanceof CollectionPage || current instanceof OrderedCollectionPage ? current : null;
292
+ const pageId = page == null ? current.firstId : page.nextId;
293
+ if (pageId != null && visitedIds.has(pageId.href)) return;
294
+ try {
295
+ budget.signal?.throwIfAborted();
296
+ current = page == null ? await current.getFirst(pageOptions) : await page.getNext(pageOptions);
297
+ } catch (error) {
298
+ if (error instanceof MaxRequestsExceeded) throw error;
299
+ budget.signal?.throwIfAborted();
300
+ return;
301
+ }
302
+ if (pageId != null && pageId.href !== current?.id?.href) visitedIds.add(pageId.href);
303
+ }
267
304
  }
268
305
  async function getCreateActivityObject(context, object, options, budget) {
269
306
  if (!(object instanceof Create)) return null;
@@ -285,7 +322,7 @@ async function loadCollectionItemDocument(context, url, options, budget, skipIds
285
322
  try {
286
323
  const iri = new URL(url);
287
324
  if (skipIds?.has(iri.href)) return skippedCollectionItemDocument(url);
288
- object = await loadObject(context, iri, options, budget, true);
325
+ object = await loadObject(context, iri, options, budget, { throwOnBudgetExceeded: true });
289
326
  } catch (error) {
290
327
  if (error instanceof MaxRequestsExceeded) throw error;
291
328
  budget.signal?.throwIfAborted();
@@ -308,7 +345,7 @@ function skippedCollectionItemDocument(url) {
308
345
  }
309
346
  };
310
347
  }
311
- async function loadObject(context, iri, options, budget, throwOnBudgetExceeded = false) {
348
+ async function loadObject(context, iri, options, budget, { throwOnBudgetExceeded = false, skipLoaderErrors = false } = {}) {
312
349
  budget.signal?.throwIfAborted();
313
350
  const cacheKey = iri.href;
314
351
  const cached = budget.documents.get(cacheKey);
@@ -320,12 +357,17 @@ async function loadObject(context, iri, options, budget, throwOnBudgetExceeded =
320
357
  await waitForInterval(options, budget);
321
358
  budget.signal?.throwIfAborted();
322
359
  budget.requestCount++;
323
- const document = context.documentLoader(iri, { signal: budget.signal });
324
- budget.documents.set(cacheKey, document);
360
+ let document;
325
361
  try {
362
+ document = context.documentLoader(iri, { signal: budget.signal });
363
+ budget.documents.set(cacheKey, document);
326
364
  return await document;
327
365
  } catch (error) {
328
366
  if (budget.documents.get(cacheKey) === document) budget.documents.delete(cacheKey);
367
+ if (skipLoaderErrors) {
368
+ budget.signal?.throwIfAborted();
369
+ return null;
370
+ }
329
371
  throw error;
330
372
  }
331
373
  }
@@ -1537,5 +1579,293 @@ describe("backfill", () => {
1537
1579
  deepStrictEqual(iterations, [0, 1]);
1538
1580
  });
1539
1581
  });
1582
+ describe("collection pagination", () => {
1583
+ const iri = (name) => new URL(`https://example.com/${name}`);
1584
+ for (const ordered of [false, true]) for (const replies of [false, true]) for (const embedded of [false, true]) test(`${ordered ? "ordered" : "unordered"} ${replies ? "replies" : "context"} with ${embedded ? "embedded" : "linked"} pages`, async () => {
1585
+ const CollectionType = ordered ? OrderedCollection : Collection;
1586
+ const PageType = ordered ? OrderedCollectionPage : CollectionPage;
1587
+ const a = new Note({ id: iri("a") });
1588
+ const b = new Note({ id: iri("b") });
1589
+ const second = new PageType({
1590
+ id: iri("page-2"),
1591
+ items: [b]
1592
+ });
1593
+ const first = new PageType({
1594
+ id: iri("page-1"),
1595
+ items: [a],
1596
+ next: embedded ? second : second.id
1597
+ });
1598
+ const collection = new CollectionType({
1599
+ id: iri("thread"),
1600
+ first: embedded ? first : first.id
1601
+ });
1602
+ const seed = new Note({
1603
+ id: iri("seed"),
1604
+ contexts: replies ? [] : [iri("thread")],
1605
+ replies: replies ? collection : null
1606
+ });
1607
+ const documents = /* @__PURE__ */ new Map([
1608
+ [iri("thread").href, collection],
1609
+ [iri("page-1").href, first],
1610
+ [iri("page-2").href, second]
1611
+ ]);
1612
+ const calls = [];
1613
+ const items = await collect({ documentLoader: (url) => {
1614
+ calls.push(url.href);
1615
+ return Promise.resolve(documents.get(url.href) ?? null);
1616
+ } }, seed, {
1617
+ strategies: replies ? ["reply-tree"] : ["context-auto"],
1618
+ maxDepth: 1,
1619
+ maxRequests: embedded ? replies ? 0 : 1 : 3
1620
+ });
1621
+ deepStrictEqual(items.map((item) => item.id?.href), [a.id?.href, b.id?.href]);
1622
+ deepStrictEqual(items.map((item) => item.depth), replies ? [1, 1] : [0, 0]);
1623
+ strictEqual(calls.length, embedded ? replies ? 0 : 1 : replies ? 2 : 3);
1624
+ });
1625
+ test("starting page follows next and stops before loading a repeated page", async () => {
1626
+ const first = new OrderedCollectionPage({
1627
+ id: iri("page-1"),
1628
+ items: [new Note({ id: iri("a") })],
1629
+ next: iri("page-2")
1630
+ });
1631
+ const second = new OrderedCollectionPage({
1632
+ id: iri("page-2"),
1633
+ items: [new Note({ id: iri("b") })],
1634
+ next: first.id
1635
+ });
1636
+ const calls = [];
1637
+ deepStrictEqual((await collect({ documentLoader: (url) => {
1638
+ calls.push(url.href);
1639
+ return Promise.resolve(url.href === first.id?.href ? first : second);
1640
+ } }, new Note({ contexts: [first.id] }))).map((item) => item.id?.href), [iri("a").href, iri("b").href]);
1641
+ deepStrictEqual(calls, [first.id?.href, second.id?.href]);
1642
+ });
1643
+ for (const limit of ["maxItems", "maxRequests"]) test(`${limit} stops between pages`, async () => {
1644
+ const first = new CollectionPage({
1645
+ id: iri("page-1"),
1646
+ items: [new Note({ id: iri("a") })],
1647
+ next: iri("page-2")
1648
+ });
1649
+ const calls = [];
1650
+ deepStrictEqual((await collect({ documentLoader: (url) => {
1651
+ calls.push(url.href);
1652
+ return Promise.resolve(first);
1653
+ } }, new Note({
1654
+ contexts: [first.id],
1655
+ replies: new Collection({ items: [new Note({ id: iri("fallback") })] })
1656
+ }), {
1657
+ [limit]: 1,
1658
+ strategies: ["context-auto", "reply-tree"]
1659
+ })).map((item) => item.id?.href), [iri("a").href]);
1660
+ deepStrictEqual(calls, [first.id?.href]);
1661
+ });
1662
+ for (const failure of [
1663
+ "missing",
1664
+ "throw",
1665
+ "invalid"
1666
+ ]) test(`${failure} page retains items and allows the next strategy`, async () => {
1667
+ const seed = new Note({
1668
+ contexts: [iri("thread")],
1669
+ replies: new Collection({ items: [new Note({ id: iri("reply") })] })
1670
+ });
1671
+ const first = new CollectionPage({
1672
+ items: [new Note({ id: iri("a") })],
1673
+ next: iri("broken")
1674
+ });
1675
+ deepStrictEqual((await collect({ documentLoader: (url) => {
1676
+ if (url.href === iri("thread").href) return Promise.resolve(new Collection({ first }));
1677
+ if (failure === "throw") throw new Error("page failed");
1678
+ return Promise.resolve(failure === "missing" ? null : new Note({}));
1679
+ } }, seed, { strategies: ["context-auto", "reply-tree"] })).map((item) => item.id?.href), [iri("a").href, iri("reply").href]);
1680
+ });
1681
+ test("inline items precede page items", async () => {
1682
+ deepStrictEqual((await collect({ documentLoader: () => Promise.resolve(new Collection({
1683
+ items: [new Note({ id: iri("inline") })],
1684
+ first: new CollectionPage({ items: [new Note({ id: iri("paged") })] })
1685
+ })) }, new Note({ contexts: [iri("thread")] }))).map((item) => item.id?.href), [iri("inline").href, iri("paged").href]);
1686
+ });
1687
+ test("cancellation during a page load prevents later strategies", async () => {
1688
+ const controller = new AbortController();
1689
+ const reason = /* @__PURE__ */ new Error("cancelled");
1690
+ await rejects(collect({ documentLoader: (url, options) => {
1691
+ strictEqual(options?.signal, controller.signal);
1692
+ if (url.href === iri("thread").href) return Promise.resolve(new Collection({ first: iri("page") }));
1693
+ controller.abort(reason);
1694
+ throw reason;
1695
+ } }, new Note({
1696
+ contexts: [iri("thread")],
1697
+ replies: new Collection({ items: [new Note({ id: iri("fallback") })] })
1698
+ }), {
1699
+ signal: controller.signal,
1700
+ strategies: ["context-auto", "reply-tree"]
1701
+ }), (error) => error === reason);
1702
+ });
1703
+ });
1704
+ test("page loads share the cache and interval across strategies", async () => {
1705
+ const iri = (name) => new URL(`https://example.com/${name}`);
1706
+ const collection = new Collection({ first: iri("page") });
1707
+ const page = new CollectionPage({ items: [new Note({ id: iri("a") })] });
1708
+ const seed = new Note({
1709
+ contexts: [iri("thread")],
1710
+ replies: new Collection({ first: iri("page") })
1711
+ });
1712
+ const calls = [];
1713
+ const intervals = [];
1714
+ deepStrictEqual((await collect({ documentLoader: (url) => {
1715
+ calls.push(url.href);
1716
+ return Promise.resolve(url.href === iri("thread").href ? collection : page);
1717
+ } }, seed, {
1718
+ strategies: ["context-auto", "reply-tree"],
1719
+ maxRequests: 2,
1720
+ interval: (count) => {
1721
+ intervals.push(count);
1722
+ return { milliseconds: 0 };
1723
+ }
1724
+ })).map((item) => item.id?.href), [iri("a").href]);
1725
+ deepStrictEqual(calls, [iri("thread").href, iri("page").href]);
1726
+ deepStrictEqual(intervals, [0, 1]);
1727
+ });
1728
+ test("embedded pages without IDs follow next without loader calls", async () => {
1729
+ const a = new Note({ id: new URL("https://example.com/a") });
1730
+ const b = new Note({ id: new URL("https://example.com/b") });
1731
+ deepStrictEqual((await collect({ documentLoader: () => {
1732
+ throw new Error("embedded pages must not be fetched");
1733
+ } }, new Note({ replies: new Collection({ first: new CollectionPage({
1734
+ items: [a],
1735
+ next: new CollectionPage({ items: [b] })
1736
+ }) }) }), {
1737
+ strategies: ["reply-tree"],
1738
+ maxRequests: 0,
1739
+ maxDepth: 1
1740
+ })).map((item) => item.id?.href), [a.id?.href, b.id?.href]);
1741
+ });
1742
+ describe("context loader failures", () => {
1743
+ const contextId = new URL("https://example.com/thread");
1744
+ const parent = new Note({ id: new URL("https://example.com/parent") });
1745
+ for (const synchronous of [false, true]) {
1746
+ const failure = synchronous ? "synchronous throw" : "promise rejection";
1747
+ const fail = () => {
1748
+ const error = /* @__PURE__ */ new Error("Context collection unavailable");
1749
+ if (synchronous) throw error;
1750
+ return Promise.reject(error);
1751
+ };
1752
+ test(`${failure} allows reply-tree to yield an embedded parent`, async () => {
1753
+ const seed = new Note({
1754
+ contexts: [contextId],
1755
+ replyTarget: parent
1756
+ });
1757
+ let requests = 0;
1758
+ const items = await collect({ documentLoader: () => {
1759
+ requests++;
1760
+ return fail();
1761
+ } }, seed, {
1762
+ strategies: ["context-auto", "reply-tree"],
1763
+ maxRequests: 1
1764
+ });
1765
+ deepStrictEqual(items.map((item) => item.object), [parent]);
1766
+ strictEqual(items[0].strategy, "reply-tree");
1767
+ strictEqual(requests, 1);
1768
+ });
1769
+ test(`${failure} finishes the default strategy without items`, async () => {
1770
+ const seed = new Note({ contexts: [contextId] });
1771
+ deepStrictEqual(await collect({ documentLoader: fail }, seed), []);
1772
+ });
1773
+ test(`${failure} consumes the request budget`, async () => {
1774
+ const seed = new Note({
1775
+ contexts: [contextId],
1776
+ replyTarget: parent.id
1777
+ });
1778
+ const requests = [];
1779
+ deepStrictEqual(await collect({ documentLoader: (url) => {
1780
+ requests.push(url.href);
1781
+ if (url.href === contextId.href) return fail();
1782
+ return Promise.resolve(parent);
1783
+ } }, seed, {
1784
+ strategies: ["context-auto", "reply-tree"],
1785
+ maxRequests: 1
1786
+ }), []);
1787
+ deepStrictEqual(requests, [contextId.href]);
1788
+ });
1789
+ test(`${failure} propagates the cancellation reason`, async () => {
1790
+ const controller = new AbortController();
1791
+ const reason = /* @__PURE__ */ new Error("Stop backfill");
1792
+ const seed = new Note({
1793
+ contexts: [contextId],
1794
+ replyTarget: parent
1795
+ });
1796
+ const yielded = [];
1797
+ await rejects(async () => {
1798
+ for await (const item of backfill({ documentLoader: (_url, options) => {
1799
+ strictEqual(options?.signal, controller.signal);
1800
+ controller.abort(reason);
1801
+ return fail();
1802
+ } }, seed, {
1803
+ strategies: ["context-auto", "reply-tree"],
1804
+ signal: controller.signal
1805
+ })) yielded.push(item);
1806
+ }, (error) => error === reason);
1807
+ deepStrictEqual(yielded, []);
1808
+ });
1809
+ }
1810
+ test("a failed context load is not cached for later reply-tree loads", async () => {
1811
+ const seed = new Note({
1812
+ contexts: [contextId],
1813
+ replyTarget: contextId
1814
+ });
1815
+ let requests = 0;
1816
+ deepStrictEqual((await collect({ documentLoader: (url) => {
1817
+ strictEqual(url.href, contextId.href);
1818
+ requests++;
1819
+ if (requests === 1) return Promise.reject(/* @__PURE__ */ new Error("Temporary failure"));
1820
+ return Promise.resolve(parent);
1821
+ } }, seed, {
1822
+ strategies: ["context-auto", "reply-tree"],
1823
+ maxRequests: 2
1824
+ })).map((item) => item.object.id?.href), [parent.id?.href]);
1825
+ strictEqual(requests, 2);
1826
+ });
1827
+ test("an exhausted budget still allows embedded reply-tree data", async () => {
1828
+ deepStrictEqual((await collect({ documentLoader: () => {
1829
+ throw new Error("No requests allowed");
1830
+ } }, new Note({
1831
+ contexts: [contextId],
1832
+ replyTarget: parent
1833
+ }), {
1834
+ strategies: ["context-auto", "reply-tree"],
1835
+ maxRequests: 0
1836
+ })).map((item) => item.object), [parent]);
1837
+ });
1838
+ test("interval callback errors propagate before the loader is called", async () => {
1839
+ const reason = /* @__PURE__ */ new Error("Invalid interval configuration");
1840
+ let requests = 0;
1841
+ await rejects(collect({ documentLoader: () => {
1842
+ requests++;
1843
+ return Promise.resolve(null);
1844
+ } }, new Note({
1845
+ contexts: [contextId],
1846
+ replyTarget: parent
1847
+ }), {
1848
+ strategies: ["context-auto", "reply-tree"],
1849
+ interval: () => {
1850
+ throw reason;
1851
+ }
1852
+ }), (error) => error === reason);
1853
+ strictEqual(requests, 0);
1854
+ });
1855
+ test("invalid interval strings still propagate", async () => {
1856
+ let requests = 0;
1857
+ await rejects(collect({ documentLoader: () => {
1858
+ requests++;
1859
+ return Promise.resolve(null);
1860
+ } }, new Note({
1861
+ contexts: [contextId],
1862
+ replyTarget: parent
1863
+ }), {
1864
+ strategies: ["context-auto", "reply-tree"],
1865
+ interval: "not a duration"
1866
+ }));
1867
+ strictEqual(requests, 0);
1868
+ });
1869
+ });
1540
1870
  //#endregion
1541
1871
  export {};
package/dist/mod.cjs CHANGED
@@ -90,7 +90,7 @@ function currentContextGroupHasAuto(strategies) {
90
90
  async function* getContextStrategyItems(context, note, strategies, options, budget, seenIds) {
91
91
  const contextId = note.contextIds[0];
92
92
  if (contextId == null) return;
93
- const collection = await loadObject(context, contextId, options, budget);
93
+ const collection = await loadObject(context, contextId, options, budget, { skipLoaderErrors: true });
94
94
  if (!isCollection(collection)) return;
95
95
  for await (const object of getCollectionItems(context, collection, options, budget, seenIds)) for (const strategy of strategies) for await (const item of getContextBackfillItems(context, object, strategy, options, budget)) yield {
96
96
  object: item.object,
@@ -257,12 +257,49 @@ async function* getContextBackfillItems(context, object, strategy, options, budg
257
257
  }
258
258
  }
259
259
  async function* getCollectionItems(context, collection, options, budget, skipIds) {
260
- yield* collection.getItems({
260
+ const itemOptions = {
261
261
  documentLoader: async (url) => {
262
262
  return await loadCollectionItemDocument(context, url, options, budget, skipIds);
263
263
  },
264
264
  crossOrigin: "trust"
265
- });
265
+ };
266
+ const pageOptions = {
267
+ documentLoader: async (url) => {
268
+ const object = await loadObject(context, new URL(url), options, budget, { throwOnBudgetExceeded: true });
269
+ if (object == null) throw new Error(`Collection page not found: ${url}`);
270
+ return {
271
+ contextUrl: null,
272
+ documentUrl: url,
273
+ document: await object.toJsonLd()
274
+ };
275
+ },
276
+ crossOrigin: "trust"
277
+ };
278
+ const visitedIds = /* @__PURE__ */ new Set();
279
+ const visitedPages = /* @__PURE__ */ new WeakSet();
280
+ let current = collection;
281
+ while (current != null) {
282
+ budget.signal?.throwIfAborted();
283
+ if (visitedPages.has(current)) return;
284
+ if (current.id != null) {
285
+ if (visitedIds.has(current.id.href)) return;
286
+ visitedIds.add(current.id.href);
287
+ }
288
+ visitedPages.add(current);
289
+ yield* current.getItems(itemOptions);
290
+ const page = current instanceof _fedify_vocab.CollectionPage || current instanceof _fedify_vocab.OrderedCollectionPage ? current : null;
291
+ const pageId = page == null ? current.firstId : page.nextId;
292
+ if (pageId != null && visitedIds.has(pageId.href)) return;
293
+ try {
294
+ budget.signal?.throwIfAborted();
295
+ current = page == null ? await current.getFirst(pageOptions) : await page.getNext(pageOptions);
296
+ } catch (error) {
297
+ if (error instanceof MaxRequestsExceeded) throw error;
298
+ budget.signal?.throwIfAborted();
299
+ return;
300
+ }
301
+ if (pageId != null && pageId.href !== current?.id?.href) visitedIds.add(pageId.href);
302
+ }
266
303
  }
267
304
  async function getCreateActivityObject(context, object, options, budget) {
268
305
  if (!(object instanceof _fedify_vocab.Create)) return null;
@@ -284,7 +321,7 @@ async function loadCollectionItemDocument(context, url, options, budget, skipIds
284
321
  try {
285
322
  const iri = new URL(url);
286
323
  if (skipIds?.has(iri.href)) return skippedCollectionItemDocument(url);
287
- object = await loadObject(context, iri, options, budget, true);
324
+ object = await loadObject(context, iri, options, budget, { throwOnBudgetExceeded: true });
288
325
  } catch (error) {
289
326
  if (error instanceof MaxRequestsExceeded) throw error;
290
327
  budget.signal?.throwIfAborted();
@@ -307,7 +344,7 @@ function skippedCollectionItemDocument(url) {
307
344
  }
308
345
  };
309
346
  }
310
- async function loadObject(context, iri, options, budget, throwOnBudgetExceeded = false) {
347
+ async function loadObject(context, iri, options, budget, { throwOnBudgetExceeded = false, skipLoaderErrors = false } = {}) {
311
348
  budget.signal?.throwIfAborted();
312
349
  const cacheKey = iri.href;
313
350
  const cached = budget.documents.get(cacheKey);
@@ -319,12 +356,17 @@ async function loadObject(context, iri, options, budget, throwOnBudgetExceeded =
319
356
  await waitForInterval(options, budget);
320
357
  budget.signal?.throwIfAborted();
321
358
  budget.requestCount++;
322
- const document = context.documentLoader(iri, { signal: budget.signal });
323
- budget.documents.set(cacheKey, document);
359
+ let document;
324
360
  try {
361
+ document = context.documentLoader(iri, { signal: budget.signal });
362
+ budget.documents.set(cacheKey, document);
325
363
  return await document;
326
364
  } catch (error) {
327
365
  if (budget.documents.get(cacheKey) === document) budget.documents.delete(cacheKey);
366
+ if (skipLoaderErrors) {
367
+ budget.signal?.throwIfAborted();
368
+ return null;
369
+ }
328
370
  throw error;
329
371
  }
330
372
  }
package/dist/mod.js CHANGED
@@ -89,7 +89,7 @@ function currentContextGroupHasAuto(strategies) {
89
89
  async function* getContextStrategyItems(context, note, strategies, options, budget, seenIds) {
90
90
  const contextId = note.contextIds[0];
91
91
  if (contextId == null) return;
92
- const collection = await loadObject(context, contextId, options, budget);
92
+ const collection = await loadObject(context, contextId, options, budget, { skipLoaderErrors: true });
93
93
  if (!isCollection(collection)) return;
94
94
  for await (const object of getCollectionItems(context, collection, options, budget, seenIds)) for (const strategy of strategies) for await (const item of getContextBackfillItems(context, object, strategy, options, budget)) yield {
95
95
  object: item.object,
@@ -256,12 +256,49 @@ async function* getContextBackfillItems(context, object, strategy, options, budg
256
256
  }
257
257
  }
258
258
  async function* getCollectionItems(context, collection, options, budget, skipIds) {
259
- yield* collection.getItems({
259
+ const itemOptions = {
260
260
  documentLoader: async (url) => {
261
261
  return await loadCollectionItemDocument(context, url, options, budget, skipIds);
262
262
  },
263
263
  crossOrigin: "trust"
264
- });
264
+ };
265
+ const pageOptions = {
266
+ documentLoader: async (url) => {
267
+ const object = await loadObject(context, new URL(url), options, budget, { throwOnBudgetExceeded: true });
268
+ if (object == null) throw new Error(`Collection page not found: ${url}`);
269
+ return {
270
+ contextUrl: null,
271
+ documentUrl: url,
272
+ document: await object.toJsonLd()
273
+ };
274
+ },
275
+ crossOrigin: "trust"
276
+ };
277
+ const visitedIds = /* @__PURE__ */ new Set();
278
+ const visitedPages = /* @__PURE__ */ new WeakSet();
279
+ let current = collection;
280
+ while (current != null) {
281
+ budget.signal?.throwIfAborted();
282
+ if (visitedPages.has(current)) return;
283
+ if (current.id != null) {
284
+ if (visitedIds.has(current.id.href)) return;
285
+ visitedIds.add(current.id.href);
286
+ }
287
+ visitedPages.add(current);
288
+ yield* current.getItems(itemOptions);
289
+ const page = current instanceof CollectionPage || current instanceof OrderedCollectionPage ? current : null;
290
+ const pageId = page == null ? current.firstId : page.nextId;
291
+ if (pageId != null && visitedIds.has(pageId.href)) return;
292
+ try {
293
+ budget.signal?.throwIfAborted();
294
+ current = page == null ? await current.getFirst(pageOptions) : await page.getNext(pageOptions);
295
+ } catch (error) {
296
+ if (error instanceof MaxRequestsExceeded) throw error;
297
+ budget.signal?.throwIfAborted();
298
+ return;
299
+ }
300
+ if (pageId != null && pageId.href !== current?.id?.href) visitedIds.add(pageId.href);
301
+ }
265
302
  }
266
303
  async function getCreateActivityObject(context, object, options, budget) {
267
304
  if (!(object instanceof Create)) return null;
@@ -283,7 +320,7 @@ async function loadCollectionItemDocument(context, url, options, budget, skipIds
283
320
  try {
284
321
  const iri = new URL(url);
285
322
  if (skipIds?.has(iri.href)) return skippedCollectionItemDocument(url);
286
- object = await loadObject(context, iri, options, budget, true);
323
+ object = await loadObject(context, iri, options, budget, { throwOnBudgetExceeded: true });
287
324
  } catch (error) {
288
325
  if (error instanceof MaxRequestsExceeded) throw error;
289
326
  budget.signal?.throwIfAborted();
@@ -306,7 +343,7 @@ function skippedCollectionItemDocument(url) {
306
343
  }
307
344
  };
308
345
  }
309
- async function loadObject(context, iri, options, budget, throwOnBudgetExceeded = false) {
346
+ async function loadObject(context, iri, options, budget, { throwOnBudgetExceeded = false, skipLoaderErrors = false } = {}) {
310
347
  budget.signal?.throwIfAborted();
311
348
  const cacheKey = iri.href;
312
349
  const cached = budget.documents.get(cacheKey);
@@ -318,12 +355,17 @@ async function loadObject(context, iri, options, budget, throwOnBudgetExceeded =
318
355
  await waitForInterval(options, budget);
319
356
  budget.signal?.throwIfAborted();
320
357
  budget.requestCount++;
321
- const document = context.documentLoader(iri, { signal: budget.signal });
322
- budget.documents.set(cacheKey, document);
358
+ let document;
323
359
  try {
360
+ document = context.documentLoader(iri, { signal: budget.signal });
361
+ budget.documents.set(cacheKey, document);
324
362
  return await document;
325
363
  } catch (error) {
326
364
  if (budget.documents.get(cacheKey) === document) budget.documents.delete(cacheKey);
365
+ if (skipLoaderErrors) {
366
+ budget.signal?.throwIfAborted();
367
+ return null;
368
+ }
327
369
  throw error;
328
370
  }
329
371
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/backfill",
3
- "version": "2.4.2-dev.2362+46a1084e",
3
+ "version": "2.4.2",
4
4
  "description": "ActivityPub backfill support for Fedify",
5
5
  "keywords": [
6
6
  "Fedify",
@@ -46,7 +46,7 @@
46
46
  "README.md"
47
47
  ],
48
48
  "dependencies": {
49
- "@fedify/vocab": "2.4.2-dev.2362+46a1084e"
49
+ "@fedify/vocab": "2.4.2"
50
50
  },
51
51
  "devDependencies": {
52
52
  "tsdown": "^0.22.0",