@fedify/backfill 2.5.0-dev.2364 → 2.5.0-dev.2378
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 +14 -2
- package/dist/backfill.test.cjs +337 -7
- package/dist/backfill.test.js +337 -7
- package/dist/mod.cjs +49 -7
- package/dist/mod.js +49 -7
- package/package.json +2 -2
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
|
package/dist/backfill.test.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/backfill.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.5.0-dev.
|
|
3
|
+
"version": "2.5.0-dev.2378+fea9c751",
|
|
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.5.0-dev.
|
|
49
|
+
"@fedify/vocab": "2.5.0-dev.2378+fea9c751"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"tsdown": "^0.22.0",
|