@fedify/backfill 2.3.11-dev.2296 → 2.3.12-dev.2341
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 +9 -1
- package/dist/backfill.test.cjs +199 -2
- package/dist/backfill.test.js +199 -2
- package/dist/mod.cjs +39 -2
- package/dist/mod.js +39 -2
- 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
|
package/dist/backfill.test.cjs
CHANGED
|
@@ -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, 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;
|
|
@@ -1560,4 +1597,164 @@ async function collect(context, note, options = {}) {
|
|
|
1560
1597
|
(0, node_assert_strict.deepStrictEqual)(iterations, [0, 1]);
|
|
1561
1598
|
});
|
|
1562
1599
|
});
|
|
1600
|
+
(0, node_test.describe)("collection pagination", () => {
|
|
1601
|
+
const iri = (name) => new URL(`https://example.com/${name}`);
|
|
1602
|
+
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 () => {
|
|
1603
|
+
const CollectionType = ordered ? _fedify_vocab.OrderedCollection : _fedify_vocab.Collection;
|
|
1604
|
+
const PageType = ordered ? _fedify_vocab.OrderedCollectionPage : _fedify_vocab.CollectionPage;
|
|
1605
|
+
const a = new _fedify_vocab.Note({ id: iri("a") });
|
|
1606
|
+
const b = new _fedify_vocab.Note({ id: iri("b") });
|
|
1607
|
+
const second = new PageType({
|
|
1608
|
+
id: iri("page-2"),
|
|
1609
|
+
items: [b]
|
|
1610
|
+
});
|
|
1611
|
+
const first = new PageType({
|
|
1612
|
+
id: iri("page-1"),
|
|
1613
|
+
items: [a],
|
|
1614
|
+
next: embedded ? second : second.id
|
|
1615
|
+
});
|
|
1616
|
+
const collection = new CollectionType({
|
|
1617
|
+
id: iri("thread"),
|
|
1618
|
+
first: embedded ? first : first.id
|
|
1619
|
+
});
|
|
1620
|
+
const seed = new _fedify_vocab.Note({
|
|
1621
|
+
id: iri("seed"),
|
|
1622
|
+
contexts: replies ? [] : [iri("thread")],
|
|
1623
|
+
replies: replies ? collection : null
|
|
1624
|
+
});
|
|
1625
|
+
const documents = new Map([
|
|
1626
|
+
[iri("thread").href, collection],
|
|
1627
|
+
[iri("page-1").href, first],
|
|
1628
|
+
[iri("page-2").href, second]
|
|
1629
|
+
]);
|
|
1630
|
+
const calls = [];
|
|
1631
|
+
const items = await collect({ documentLoader: (url) => {
|
|
1632
|
+
calls.push(url.href);
|
|
1633
|
+
return Promise.resolve(documents.get(url.href) ?? null);
|
|
1634
|
+
} }, seed, {
|
|
1635
|
+
strategies: replies ? ["reply-tree"] : ["context-auto"],
|
|
1636
|
+
maxDepth: 1,
|
|
1637
|
+
maxRequests: embedded ? replies ? 0 : 1 : 3
|
|
1638
|
+
});
|
|
1639
|
+
(0, node_assert_strict.deepStrictEqual)(items.map((item) => item.id?.href), [a.id?.href, b.id?.href]);
|
|
1640
|
+
(0, node_assert_strict.deepStrictEqual)(items.map((item) => item.depth), replies ? [1, 1] : [0, 0]);
|
|
1641
|
+
(0, node_assert_strict.strictEqual)(calls.length, embedded ? replies ? 0 : 1 : replies ? 2 : 3);
|
|
1642
|
+
});
|
|
1643
|
+
(0, node_test.default)("starting page follows next and stops before loading a repeated page", async () => {
|
|
1644
|
+
const first = new _fedify_vocab.OrderedCollectionPage({
|
|
1645
|
+
id: iri("page-1"),
|
|
1646
|
+
items: [new _fedify_vocab.Note({ id: iri("a") })],
|
|
1647
|
+
next: iri("page-2")
|
|
1648
|
+
});
|
|
1649
|
+
const second = new _fedify_vocab.OrderedCollectionPage({
|
|
1650
|
+
id: iri("page-2"),
|
|
1651
|
+
items: [new _fedify_vocab.Note({ id: iri("b") })],
|
|
1652
|
+
next: first.id
|
|
1653
|
+
});
|
|
1654
|
+
const calls = [];
|
|
1655
|
+
(0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: (url) => {
|
|
1656
|
+
calls.push(url.href);
|
|
1657
|
+
return Promise.resolve(url.href === first.id?.href ? first : second);
|
|
1658
|
+
} }, new _fedify_vocab.Note({ contexts: [first.id] }))).map((item) => item.id?.href), [iri("a").href, iri("b").href]);
|
|
1659
|
+
(0, node_assert_strict.deepStrictEqual)(calls, [first.id?.href, second.id?.href]);
|
|
1660
|
+
});
|
|
1661
|
+
for (const limit of ["maxItems", "maxRequests"]) (0, node_test.default)(`${limit} stops between pages`, async () => {
|
|
1662
|
+
const first = new _fedify_vocab.CollectionPage({
|
|
1663
|
+
id: iri("page-1"),
|
|
1664
|
+
items: [new _fedify_vocab.Note({ id: iri("a") })],
|
|
1665
|
+
next: iri("page-2")
|
|
1666
|
+
});
|
|
1667
|
+
const calls = [];
|
|
1668
|
+
(0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: (url) => {
|
|
1669
|
+
calls.push(url.href);
|
|
1670
|
+
return Promise.resolve(first);
|
|
1671
|
+
} }, new _fedify_vocab.Note({
|
|
1672
|
+
contexts: [first.id],
|
|
1673
|
+
replies: new _fedify_vocab.Collection({ items: [new _fedify_vocab.Note({ id: iri("fallback") })] })
|
|
1674
|
+
}), {
|
|
1675
|
+
[limit]: 1,
|
|
1676
|
+
strategies: ["context-auto", "reply-tree"]
|
|
1677
|
+
})).map((item) => item.id?.href), [iri("a").href]);
|
|
1678
|
+
(0, node_assert_strict.deepStrictEqual)(calls, [first.id?.href]);
|
|
1679
|
+
});
|
|
1680
|
+
for (const failure of [
|
|
1681
|
+
"missing",
|
|
1682
|
+
"throw",
|
|
1683
|
+
"invalid"
|
|
1684
|
+
]) (0, node_test.default)(`${failure} page retains items and allows the next strategy`, async () => {
|
|
1685
|
+
const seed = new _fedify_vocab.Note({
|
|
1686
|
+
contexts: [iri("thread")],
|
|
1687
|
+
replies: new _fedify_vocab.Collection({ items: [new _fedify_vocab.Note({ id: iri("reply") })] })
|
|
1688
|
+
});
|
|
1689
|
+
const first = new _fedify_vocab.CollectionPage({
|
|
1690
|
+
items: [new _fedify_vocab.Note({ id: iri("a") })],
|
|
1691
|
+
next: iri("broken")
|
|
1692
|
+
});
|
|
1693
|
+
(0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: (url) => {
|
|
1694
|
+
if (url.href === iri("thread").href) return Promise.resolve(new _fedify_vocab.Collection({ first }));
|
|
1695
|
+
if (failure === "throw") throw new Error("page failed");
|
|
1696
|
+
return Promise.resolve(failure === "missing" ? null : new _fedify_vocab.Note({}));
|
|
1697
|
+
} }, seed, { strategies: ["context-auto", "reply-tree"] })).map((item) => item.id?.href), [iri("a").href, iri("reply").href]);
|
|
1698
|
+
});
|
|
1699
|
+
(0, node_test.default)("inline items precede page items", async () => {
|
|
1700
|
+
(0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: () => Promise.resolve(new _fedify_vocab.Collection({
|
|
1701
|
+
items: [new _fedify_vocab.Note({ id: iri("inline") })],
|
|
1702
|
+
first: new _fedify_vocab.CollectionPage({ items: [new _fedify_vocab.Note({ id: iri("paged") })] })
|
|
1703
|
+
})) }, new _fedify_vocab.Note({ contexts: [iri("thread")] }))).map((item) => item.id?.href), [iri("inline").href, iri("paged").href]);
|
|
1704
|
+
});
|
|
1705
|
+
(0, node_test.default)("cancellation during a page load prevents later strategies", async () => {
|
|
1706
|
+
const controller = new AbortController();
|
|
1707
|
+
const reason = /* @__PURE__ */ new Error("cancelled");
|
|
1708
|
+
await (0, node_assert_strict.rejects)(collect({ documentLoader: (url, options) => {
|
|
1709
|
+
(0, node_assert_strict.strictEqual)(options?.signal, controller.signal);
|
|
1710
|
+
if (url.href === iri("thread").href) return Promise.resolve(new _fedify_vocab.Collection({ first: iri("page") }));
|
|
1711
|
+
controller.abort(reason);
|
|
1712
|
+
throw reason;
|
|
1713
|
+
} }, new _fedify_vocab.Note({
|
|
1714
|
+
contexts: [iri("thread")],
|
|
1715
|
+
replies: new _fedify_vocab.Collection({ items: [new _fedify_vocab.Note({ id: iri("fallback") })] })
|
|
1716
|
+
}), {
|
|
1717
|
+
signal: controller.signal,
|
|
1718
|
+
strategies: ["context-auto", "reply-tree"]
|
|
1719
|
+
}), (error) => error === reason);
|
|
1720
|
+
});
|
|
1721
|
+
});
|
|
1722
|
+
(0, node_test.default)("page loads share the cache and interval across strategies", async () => {
|
|
1723
|
+
const iri = (name) => new URL(`https://example.com/${name}`);
|
|
1724
|
+
const collection = new _fedify_vocab.Collection({ first: iri("page") });
|
|
1725
|
+
const page = new _fedify_vocab.CollectionPage({ items: [new _fedify_vocab.Note({ id: iri("a") })] });
|
|
1726
|
+
const seed = new _fedify_vocab.Note({
|
|
1727
|
+
contexts: [iri("thread")],
|
|
1728
|
+
replies: new _fedify_vocab.Collection({ first: iri("page") })
|
|
1729
|
+
});
|
|
1730
|
+
const calls = [];
|
|
1731
|
+
const intervals = [];
|
|
1732
|
+
(0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: (url) => {
|
|
1733
|
+
calls.push(url.href);
|
|
1734
|
+
return Promise.resolve(url.href === iri("thread").href ? collection : page);
|
|
1735
|
+
} }, seed, {
|
|
1736
|
+
strategies: ["context-auto", "reply-tree"],
|
|
1737
|
+
maxRequests: 2,
|
|
1738
|
+
interval: (count) => {
|
|
1739
|
+
intervals.push(count);
|
|
1740
|
+
return { milliseconds: 0 };
|
|
1741
|
+
}
|
|
1742
|
+
})).map((item) => item.id?.href), [iri("a").href]);
|
|
1743
|
+
(0, node_assert_strict.deepStrictEqual)(calls, [iri("thread").href, iri("page").href]);
|
|
1744
|
+
(0, node_assert_strict.deepStrictEqual)(intervals, [0, 1]);
|
|
1745
|
+
});
|
|
1746
|
+
(0, node_test.default)("embedded pages without IDs follow next without loader calls", async () => {
|
|
1747
|
+
const a = new _fedify_vocab.Note({ id: new URL("https://example.com/a") });
|
|
1748
|
+
const b = new _fedify_vocab.Note({ id: new URL("https://example.com/b") });
|
|
1749
|
+
(0, node_assert_strict.deepStrictEqual)((await collect({ documentLoader: () => {
|
|
1750
|
+
throw new Error("embedded pages must not be fetched");
|
|
1751
|
+
} }, new _fedify_vocab.Note({ replies: new _fedify_vocab.Collection({ first: new _fedify_vocab.CollectionPage({
|
|
1752
|
+
items: [a],
|
|
1753
|
+
next: new _fedify_vocab.CollectionPage({ items: [b] })
|
|
1754
|
+
}) }) }), {
|
|
1755
|
+
strategies: ["reply-tree"],
|
|
1756
|
+
maxRequests: 0,
|
|
1757
|
+
maxDepth: 1
|
|
1758
|
+
})).map((item) => item.id?.href), [a.id?.href, b.id?.href]);
|
|
1759
|
+
});
|
|
1563
1760
|
//#endregion
|
package/dist/backfill.test.js
CHANGED
|
@@ -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, 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;
|
|
@@ -1537,5 +1574,165 @@ describe("backfill", () => {
|
|
|
1537
1574
|
deepStrictEqual(iterations, [0, 1]);
|
|
1538
1575
|
});
|
|
1539
1576
|
});
|
|
1577
|
+
describe("collection pagination", () => {
|
|
1578
|
+
const iri = (name) => new URL(`https://example.com/${name}`);
|
|
1579
|
+
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 () => {
|
|
1580
|
+
const CollectionType = ordered ? OrderedCollection : Collection;
|
|
1581
|
+
const PageType = ordered ? OrderedCollectionPage : CollectionPage;
|
|
1582
|
+
const a = new Note({ id: iri("a") });
|
|
1583
|
+
const b = new Note({ id: iri("b") });
|
|
1584
|
+
const second = new PageType({
|
|
1585
|
+
id: iri("page-2"),
|
|
1586
|
+
items: [b]
|
|
1587
|
+
});
|
|
1588
|
+
const first = new PageType({
|
|
1589
|
+
id: iri("page-1"),
|
|
1590
|
+
items: [a],
|
|
1591
|
+
next: embedded ? second : second.id
|
|
1592
|
+
});
|
|
1593
|
+
const collection = new CollectionType({
|
|
1594
|
+
id: iri("thread"),
|
|
1595
|
+
first: embedded ? first : first.id
|
|
1596
|
+
});
|
|
1597
|
+
const seed = new Note({
|
|
1598
|
+
id: iri("seed"),
|
|
1599
|
+
contexts: replies ? [] : [iri("thread")],
|
|
1600
|
+
replies: replies ? collection : null
|
|
1601
|
+
});
|
|
1602
|
+
const documents = new Map([
|
|
1603
|
+
[iri("thread").href, collection],
|
|
1604
|
+
[iri("page-1").href, first],
|
|
1605
|
+
[iri("page-2").href, second]
|
|
1606
|
+
]);
|
|
1607
|
+
const calls = [];
|
|
1608
|
+
const items = await collect({ documentLoader: (url) => {
|
|
1609
|
+
calls.push(url.href);
|
|
1610
|
+
return Promise.resolve(documents.get(url.href) ?? null);
|
|
1611
|
+
} }, seed, {
|
|
1612
|
+
strategies: replies ? ["reply-tree"] : ["context-auto"],
|
|
1613
|
+
maxDepth: 1,
|
|
1614
|
+
maxRequests: embedded ? replies ? 0 : 1 : 3
|
|
1615
|
+
});
|
|
1616
|
+
deepStrictEqual(items.map((item) => item.id?.href), [a.id?.href, b.id?.href]);
|
|
1617
|
+
deepStrictEqual(items.map((item) => item.depth), replies ? [1, 1] : [0, 0]);
|
|
1618
|
+
strictEqual(calls.length, embedded ? replies ? 0 : 1 : replies ? 2 : 3);
|
|
1619
|
+
});
|
|
1620
|
+
test("starting page follows next and stops before loading a repeated page", async () => {
|
|
1621
|
+
const first = new OrderedCollectionPage({
|
|
1622
|
+
id: iri("page-1"),
|
|
1623
|
+
items: [new Note({ id: iri("a") })],
|
|
1624
|
+
next: iri("page-2")
|
|
1625
|
+
});
|
|
1626
|
+
const second = new OrderedCollectionPage({
|
|
1627
|
+
id: iri("page-2"),
|
|
1628
|
+
items: [new Note({ id: iri("b") })],
|
|
1629
|
+
next: first.id
|
|
1630
|
+
});
|
|
1631
|
+
const calls = [];
|
|
1632
|
+
deepStrictEqual((await collect({ documentLoader: (url) => {
|
|
1633
|
+
calls.push(url.href);
|
|
1634
|
+
return Promise.resolve(url.href === first.id?.href ? first : second);
|
|
1635
|
+
} }, new Note({ contexts: [first.id] }))).map((item) => item.id?.href), [iri("a").href, iri("b").href]);
|
|
1636
|
+
deepStrictEqual(calls, [first.id?.href, second.id?.href]);
|
|
1637
|
+
});
|
|
1638
|
+
for (const limit of ["maxItems", "maxRequests"]) test(`${limit} stops between pages`, async () => {
|
|
1639
|
+
const first = new CollectionPage({
|
|
1640
|
+
id: iri("page-1"),
|
|
1641
|
+
items: [new Note({ id: iri("a") })],
|
|
1642
|
+
next: iri("page-2")
|
|
1643
|
+
});
|
|
1644
|
+
const calls = [];
|
|
1645
|
+
deepStrictEqual((await collect({ documentLoader: (url) => {
|
|
1646
|
+
calls.push(url.href);
|
|
1647
|
+
return Promise.resolve(first);
|
|
1648
|
+
} }, new Note({
|
|
1649
|
+
contexts: [first.id],
|
|
1650
|
+
replies: new Collection({ items: [new Note({ id: iri("fallback") })] })
|
|
1651
|
+
}), {
|
|
1652
|
+
[limit]: 1,
|
|
1653
|
+
strategies: ["context-auto", "reply-tree"]
|
|
1654
|
+
})).map((item) => item.id?.href), [iri("a").href]);
|
|
1655
|
+
deepStrictEqual(calls, [first.id?.href]);
|
|
1656
|
+
});
|
|
1657
|
+
for (const failure of [
|
|
1658
|
+
"missing",
|
|
1659
|
+
"throw",
|
|
1660
|
+
"invalid"
|
|
1661
|
+
]) test(`${failure} page retains items and allows the next strategy`, async () => {
|
|
1662
|
+
const seed = new Note({
|
|
1663
|
+
contexts: [iri("thread")],
|
|
1664
|
+
replies: new Collection({ items: [new Note({ id: iri("reply") })] })
|
|
1665
|
+
});
|
|
1666
|
+
const first = new CollectionPage({
|
|
1667
|
+
items: [new Note({ id: iri("a") })],
|
|
1668
|
+
next: iri("broken")
|
|
1669
|
+
});
|
|
1670
|
+
deepStrictEqual((await collect({ documentLoader: (url) => {
|
|
1671
|
+
if (url.href === iri("thread").href) return Promise.resolve(new Collection({ first }));
|
|
1672
|
+
if (failure === "throw") throw new Error("page failed");
|
|
1673
|
+
return Promise.resolve(failure === "missing" ? null : new Note({}));
|
|
1674
|
+
} }, seed, { strategies: ["context-auto", "reply-tree"] })).map((item) => item.id?.href), [iri("a").href, iri("reply").href]);
|
|
1675
|
+
});
|
|
1676
|
+
test("inline items precede page items", async () => {
|
|
1677
|
+
deepStrictEqual((await collect({ documentLoader: () => Promise.resolve(new Collection({
|
|
1678
|
+
items: [new Note({ id: iri("inline") })],
|
|
1679
|
+
first: new CollectionPage({ items: [new Note({ id: iri("paged") })] })
|
|
1680
|
+
})) }, new Note({ contexts: [iri("thread")] }))).map((item) => item.id?.href), [iri("inline").href, iri("paged").href]);
|
|
1681
|
+
});
|
|
1682
|
+
test("cancellation during a page load prevents later strategies", async () => {
|
|
1683
|
+
const controller = new AbortController();
|
|
1684
|
+
const reason = /* @__PURE__ */ new Error("cancelled");
|
|
1685
|
+
await rejects(collect({ documentLoader: (url, options) => {
|
|
1686
|
+
strictEqual(options?.signal, controller.signal);
|
|
1687
|
+
if (url.href === iri("thread").href) return Promise.resolve(new Collection({ first: iri("page") }));
|
|
1688
|
+
controller.abort(reason);
|
|
1689
|
+
throw reason;
|
|
1690
|
+
} }, new Note({
|
|
1691
|
+
contexts: [iri("thread")],
|
|
1692
|
+
replies: new Collection({ items: [new Note({ id: iri("fallback") })] })
|
|
1693
|
+
}), {
|
|
1694
|
+
signal: controller.signal,
|
|
1695
|
+
strategies: ["context-auto", "reply-tree"]
|
|
1696
|
+
}), (error) => error === reason);
|
|
1697
|
+
});
|
|
1698
|
+
});
|
|
1699
|
+
test("page loads share the cache and interval across strategies", async () => {
|
|
1700
|
+
const iri = (name) => new URL(`https://example.com/${name}`);
|
|
1701
|
+
const collection = new Collection({ first: iri("page") });
|
|
1702
|
+
const page = new CollectionPage({ items: [new Note({ id: iri("a") })] });
|
|
1703
|
+
const seed = new Note({
|
|
1704
|
+
contexts: [iri("thread")],
|
|
1705
|
+
replies: new Collection({ first: iri("page") })
|
|
1706
|
+
});
|
|
1707
|
+
const calls = [];
|
|
1708
|
+
const intervals = [];
|
|
1709
|
+
deepStrictEqual((await collect({ documentLoader: (url) => {
|
|
1710
|
+
calls.push(url.href);
|
|
1711
|
+
return Promise.resolve(url.href === iri("thread").href ? collection : page);
|
|
1712
|
+
} }, seed, {
|
|
1713
|
+
strategies: ["context-auto", "reply-tree"],
|
|
1714
|
+
maxRequests: 2,
|
|
1715
|
+
interval: (count) => {
|
|
1716
|
+
intervals.push(count);
|
|
1717
|
+
return { milliseconds: 0 };
|
|
1718
|
+
}
|
|
1719
|
+
})).map((item) => item.id?.href), [iri("a").href]);
|
|
1720
|
+
deepStrictEqual(calls, [iri("thread").href, iri("page").href]);
|
|
1721
|
+
deepStrictEqual(intervals, [0, 1]);
|
|
1722
|
+
});
|
|
1723
|
+
test("embedded pages without IDs follow next without loader calls", async () => {
|
|
1724
|
+
const a = new Note({ id: new URL("https://example.com/a") });
|
|
1725
|
+
const b = new Note({ id: new URL("https://example.com/b") });
|
|
1726
|
+
deepStrictEqual((await collect({ documentLoader: () => {
|
|
1727
|
+
throw new Error("embedded pages must not be fetched");
|
|
1728
|
+
} }, new Note({ replies: new Collection({ first: new CollectionPage({
|
|
1729
|
+
items: [a],
|
|
1730
|
+
next: new CollectionPage({ items: [b] })
|
|
1731
|
+
}) }) }), {
|
|
1732
|
+
strategies: ["reply-tree"],
|
|
1733
|
+
maxRequests: 0,
|
|
1734
|
+
maxDepth: 1
|
|
1735
|
+
})).map((item) => item.id?.href), [a.id?.href, b.id?.href]);
|
|
1736
|
+
});
|
|
1540
1737
|
//#endregion
|
|
1541
1738
|
export {};
|
package/dist/mod.cjs
CHANGED
|
@@ -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, 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;
|
package/dist/mod.js
CHANGED
|
@@ -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, 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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fedify/backfill",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.12-dev.2341+eb343ebf",
|
|
4
4
|
"description": "ActivityPub backfill support for Fedify",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Fedify",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"README.md"
|
|
50
50
|
],
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@fedify/vocab": "2.3.
|
|
52
|
+
"@fedify/vocab": "2.3.12-dev.2341+eb343ebf"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"tsdown": "^0.22.0",
|