@fedify/backfill 2.3.0-dev.0

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.
@@ -0,0 +1,1541 @@
1
+ import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict";
2
+ import test, { describe } from "node:test";
3
+ import { Activity, Announce, Collection, CollectionPage, Create, Note, Object as Object$1, OrderedCollection, OrderedCollectionPage } from "@fedify/vocab";
4
+ //#region src/backfill.ts
5
+ const defaultStrategies = ["context-auto"];
6
+ const DEFAULT_MAX_DEPTH = 10;
7
+ /**
8
+ * Thrown when backfill traversal exceeds the configured request budget.
9
+ *
10
+ * @since 2.3.0
11
+ */
12
+ var MaxRequestsExceeded = class extends Error {};
13
+ /**
14
+ * Backfills post-like objects related to a seed object.
15
+ *
16
+ * The seed object is not yielded by default, but its ID is treated as already
17
+ * seen so it will not be yielded again if the collection contains it.
18
+ *
19
+ * @since 2.3.0
20
+ */
21
+ async function* backfill(context, note, options = {}) {
22
+ if (options.maxItems != null && options.maxItems <= 0) return;
23
+ const strategies = normalizeStrategies(options.strategies);
24
+ if (strategies.length < 1) return;
25
+ const budget = {
26
+ signal: options.signal,
27
+ requestCount: 0,
28
+ documents: /* @__PURE__ */ new Map()
29
+ };
30
+ const seenIds = /* @__PURE__ */ new Set();
31
+ if (note.id != null) seenIds.add(note.id.href);
32
+ let yielded = 0;
33
+ try {
34
+ for (let i = 0; i < strategies.length; i++) {
35
+ const strategy = strategies[i];
36
+ let items;
37
+ if (isContextStrategy(strategy)) {
38
+ const contextStrategies = [strategy];
39
+ while (true) {
40
+ const nextStrategy = strategies[i + 1];
41
+ if (nextStrategy == null || !isContextStrategy(nextStrategy)) break;
42
+ contextStrategies.push(nextStrategy);
43
+ i++;
44
+ }
45
+ items = getContextStrategyItems(context, note, contextStrategies, options, budget, seenIds);
46
+ } else items = getStrategyItems(context, note, strategy, options, budget, seenIds);
47
+ for await (const item of items) {
48
+ const id = item.object.id ?? void 0;
49
+ if (id != null) {
50
+ if (seenIds.has(id.href)) continue;
51
+ seenIds.add(id.href);
52
+ }
53
+ options.signal?.throwIfAborted();
54
+ yield {
55
+ object: item.object,
56
+ id,
57
+ strategy: item.strategy,
58
+ origin: item.origin,
59
+ depth: item.depth
60
+ };
61
+ yielded++;
62
+ if (options.maxItems != null && yielded >= options.maxItems) return;
63
+ }
64
+ }
65
+ } catch (error) {
66
+ if (error instanceof MaxRequestsExceeded) return;
67
+ throw error;
68
+ }
69
+ }
70
+ function normalizeStrategies(strategies = defaultStrategies) {
71
+ const normalized = [];
72
+ for (const strategy of strategies) if (strategy === "context-auto") {
73
+ for (let i = normalized.length - 1; i >= 0 && isContextStrategy(normalized[i]); i--) normalized.splice(i, 1);
74
+ if (!normalized.includes(strategy)) normalized.push(strategy);
75
+ } else if (isContextStrategy(strategy)) {
76
+ if (!currentContextGroupHasAuto(normalized) && !normalized.includes(strategy)) normalized.push(strategy);
77
+ } else if (!normalized.includes(strategy)) normalized.push(strategy);
78
+ return normalized;
79
+ }
80
+ function isContextStrategy(strategy) {
81
+ return strategy === "context-objects" || strategy === "context-activities" || strategy === "context-auto";
82
+ }
83
+ function currentContextGroupHasAuto(strategies) {
84
+ for (let i = strategies.length - 1; i >= 0; i--) {
85
+ const strategy = strategies[i];
86
+ if (!isContextStrategy(strategy)) return false;
87
+ if (strategy === "context-auto") return true;
88
+ }
89
+ return false;
90
+ }
91
+ async function* getContextStrategyItems(context, note, strategies, options, budget, seenIds) {
92
+ const contextId = note.contextIds[0];
93
+ if (contextId == null) return;
94
+ const collection = await loadObject(context, contextId, options, budget);
95
+ if (!isCollection(collection)) return;
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
+ object: item.object,
98
+ strategy: item.strategy,
99
+ origin: "collection",
100
+ depth: 0
101
+ };
102
+ }
103
+ async function* getStrategyItems(context, note, strategy, options, budget, seenIds) {
104
+ if (isContextStrategy(strategy)) yield* getContextStrategyItems(context, note, [strategy], options, budget, seenIds);
105
+ else if (strategy === "reply-tree") yield* getReplyTreeItems(context, note, options, budget);
106
+ }
107
+ async function* getReplyTreeItems(context, note, options, budget) {
108
+ const visitedObjectIds = /* @__PURE__ */ new Set();
109
+ const visitedObjects = /* @__PURE__ */ new WeakSet();
110
+ const visitedCollectionIds = /* @__PURE__ */ new Set();
111
+ const visitedCollections = /* @__PURE__ */ new WeakSet();
112
+ if (note.id != null) visitedObjectIds.add(note.id.href);
113
+ visitedObjects.add(note);
114
+ const ancestors = [];
115
+ for await (const item of getReplyAncestors(context, note, options, budget, {
116
+ depth: 1,
117
+ visitedObjectIds,
118
+ visitedObjects,
119
+ visitedCollectionIds,
120
+ visitedCollections
121
+ })) {
122
+ ancestors.push({
123
+ object: item.object,
124
+ depth: item.depth
125
+ });
126
+ yield item;
127
+ }
128
+ for (const ancestor of ancestors.toReversed()) yield* getReplyDescendants(context, ancestor.object, options, budget, {
129
+ depth: ancestor.depth + 1,
130
+ visitedObjectIds,
131
+ visitedObjects,
132
+ visitedCollectionIds,
133
+ visitedCollections
134
+ });
135
+ yield* getReplyDescendants(context, note, options, budget, {
136
+ depth: 1,
137
+ visitedObjectIds,
138
+ visitedObjects,
139
+ visitedCollectionIds,
140
+ visitedCollections
141
+ });
142
+ }
143
+ async function* getReplyAncestors(context, object, options, budget, traversal) {
144
+ if (traversal.depth > (options.maxDepth ?? DEFAULT_MAX_DEPTH)) return;
145
+ for await (const target of getReplyTargets(context, object, options, budget)) {
146
+ if (!isContextPostObject(target)) continue;
147
+ if (!visitReplyTreeObject(target, traversal)) continue;
148
+ yield {
149
+ object: target,
150
+ strategy: "reply-tree",
151
+ origin: "in-reply-to",
152
+ depth: traversal.depth
153
+ };
154
+ yield* getReplyAncestors(context, target, options, budget, {
155
+ depth: traversal.depth + 1,
156
+ visitedObjectIds: traversal.visitedObjectIds,
157
+ visitedObjects: traversal.visitedObjects,
158
+ visitedCollectionIds: traversal.visitedCollectionIds,
159
+ visitedCollections: traversal.visitedCollections
160
+ });
161
+ }
162
+ }
163
+ async function* getReplyDescendants(context, object, options, budget, traversal) {
164
+ if (traversal.depth > (options.maxDepth ?? DEFAULT_MAX_DEPTH)) return;
165
+ const repliesId = object.repliesId;
166
+ if (repliesId != null && traversal.visitedCollectionIds.has(repliesId.href)) return;
167
+ const replies = await getRepliesCollection(context, object, options, budget);
168
+ if (replies == null) return;
169
+ const unvisited = visitReplyTreeCollection(replies, traversal);
170
+ if (repliesId != null) traversal.visitedCollectionIds.add(repliesId.href);
171
+ if (!unvisited) return;
172
+ for await (const reply of getCollectionItems(context, replies, options, budget, traversal.visitedObjectIds)) {
173
+ if (!isContextPostObject(reply)) continue;
174
+ if (!visitReplyTreeObject(reply, traversal)) continue;
175
+ yield {
176
+ object: reply,
177
+ strategy: "reply-tree",
178
+ origin: "replies",
179
+ depth: traversal.depth
180
+ };
181
+ yield* getReplyDescendants(context, reply, options, budget, {
182
+ depth: traversal.depth + 1,
183
+ visitedObjectIds: traversal.visitedObjectIds,
184
+ visitedObjects: traversal.visitedObjects,
185
+ visitedCollectionIds: traversal.visitedCollectionIds,
186
+ visitedCollections: traversal.visitedCollections
187
+ });
188
+ }
189
+ }
190
+ async function* getReplyTargets(context, object, options, budget) {
191
+ try {
192
+ yield* object.getReplyTargets({
193
+ documentLoader: async (url) => {
194
+ return await loadCollectionItemDocument(context, url, options, budget);
195
+ },
196
+ crossOrigin: "trust"
197
+ });
198
+ } catch (error) {
199
+ if (error instanceof MaxRequestsExceeded) throw error;
200
+ budget.signal?.throwIfAborted();
201
+ }
202
+ }
203
+ async function getRepliesCollection(context, object, options, budget) {
204
+ try {
205
+ return await object.getReplies({
206
+ documentLoader: async (url) => {
207
+ return await loadCollectionItemDocument(context, url, options, budget);
208
+ },
209
+ crossOrigin: "trust"
210
+ });
211
+ } catch (error) {
212
+ if (error instanceof MaxRequestsExceeded) throw error;
213
+ budget.signal?.throwIfAborted();
214
+ return null;
215
+ }
216
+ }
217
+ function visitReplyTreeObject(object, traversal) {
218
+ if (object.id != null) {
219
+ if (traversal.visitedObjectIds.has(object.id.href)) return false;
220
+ traversal.visitedObjectIds.add(object.id.href);
221
+ } else if (traversal.visitedObjects.has(object)) return false;
222
+ traversal.visitedObjects.add(object);
223
+ return true;
224
+ }
225
+ function visitReplyTreeCollection(collection, traversal) {
226
+ if (collection.id != null) return visitReplyTreeCollectionId(collection.id, traversal);
227
+ else if (traversal.visitedCollections.has(collection)) return false;
228
+ traversal.visitedCollections.add(collection);
229
+ return true;
230
+ }
231
+ function visitReplyTreeCollectionId(id, traversal) {
232
+ if (traversal.visitedCollectionIds.has(id.href)) return false;
233
+ traversal.visitedCollectionIds.add(id.href);
234
+ return true;
235
+ }
236
+ async function* getContextBackfillItems(context, object, strategy, options, budget) {
237
+ if (strategy === "context-objects" && isContextPostObject(object)) yield {
238
+ object,
239
+ strategy
240
+ };
241
+ else if (strategy === "context-activities") {
242
+ const activityObject = await getCreateActivityObject(context, object, options, budget);
243
+ if (activityObject != null && isContextPostObject(activityObject)) yield {
244
+ object: activityObject,
245
+ strategy
246
+ };
247
+ } else if (strategy === "context-auto") {
248
+ if (object instanceof Activity) {
249
+ const activityObject = await getCreateActivityObject(context, object, options, budget);
250
+ if (activityObject != null && isContextPostObject(activityObject)) yield {
251
+ object: activityObject,
252
+ strategy
253
+ };
254
+ } else if (isContextPostObject(object)) yield {
255
+ object,
256
+ strategy
257
+ };
258
+ }
259
+ }
260
+ async function* getCollectionItems(context, collection, options, budget, skipIds) {
261
+ yield* collection.getItems({
262
+ documentLoader: async (url) => {
263
+ return await loadCollectionItemDocument(context, url, options, budget, skipIds);
264
+ },
265
+ crossOrigin: "trust"
266
+ });
267
+ }
268
+ async function getCreateActivityObject(context, object, options, budget) {
269
+ if (!(object instanceof Create)) return null;
270
+ try {
271
+ return await object.getObject({
272
+ documentLoader: async (url) => {
273
+ return await loadCollectionItemDocument(context, url, options, budget);
274
+ },
275
+ crossOrigin: "trust"
276
+ });
277
+ } catch (error) {
278
+ if (error instanceof MaxRequestsExceeded) throw error;
279
+ budget.signal?.throwIfAborted();
280
+ return null;
281
+ }
282
+ }
283
+ async function loadCollectionItemDocument(context, url, options, budget, skipIds) {
284
+ let object;
285
+ try {
286
+ const iri = new URL(url);
287
+ if (skipIds?.has(iri.href)) return skippedCollectionItemDocument(url);
288
+ object = await loadObject(context, iri, options, budget, true);
289
+ } catch (error) {
290
+ if (error instanceof MaxRequestsExceeded) throw error;
291
+ budget.signal?.throwIfAborted();
292
+ return skippedCollectionItemDocument(url);
293
+ }
294
+ if (object == null) return skippedCollectionItemDocument(url);
295
+ return {
296
+ contextUrl: null,
297
+ documentUrl: url,
298
+ document: await object.toJsonLd()
299
+ };
300
+ }
301
+ function skippedCollectionItemDocument(url) {
302
+ return {
303
+ contextUrl: null,
304
+ documentUrl: url,
305
+ document: {
306
+ "@context": "https://www.w3.org/ns/activitystreams",
307
+ type: "Activity"
308
+ }
309
+ };
310
+ }
311
+ async function loadObject(context, iri, options, budget, throwOnBudgetExceeded = false) {
312
+ budget.signal?.throwIfAborted();
313
+ const cacheKey = iri.href;
314
+ const cached = budget.documents.get(cacheKey);
315
+ if (cached != null) return await cached;
316
+ if (options.maxRequests != null && budget.requestCount >= options.maxRequests) {
317
+ if (throwOnBudgetExceeded) throw new MaxRequestsExceeded();
318
+ return null;
319
+ }
320
+ await waitForInterval(options, budget);
321
+ budget.signal?.throwIfAborted();
322
+ budget.requestCount++;
323
+ const document = context.documentLoader(iri, { signal: budget.signal });
324
+ budget.documents.set(cacheKey, document);
325
+ try {
326
+ return await document;
327
+ } catch (error) {
328
+ if (budget.documents.get(cacheKey) === document) budget.documents.delete(cacheKey);
329
+ throw error;
330
+ }
331
+ }
332
+ async function waitForInterval(options, budget) {
333
+ if (options.interval == null) return;
334
+ const milliseconds = durationToMilliseconds(typeof options.interval === "function" ? options.interval(budget.requestCount) : options.interval);
335
+ if (milliseconds <= 0) return;
336
+ await new Promise((resolve, reject) => {
337
+ if (budget.signal?.aborted) {
338
+ reject(budget.signal.reason);
339
+ return;
340
+ }
341
+ const timeout = setTimeout(() => {
342
+ budget.signal?.removeEventListener("abort", onAbort);
343
+ resolve();
344
+ }, milliseconds);
345
+ const onAbort = () => {
346
+ clearTimeout(timeout);
347
+ reject(budget.signal?.reason);
348
+ };
349
+ budget.signal?.addEventListener("abort", onAbort, { once: true });
350
+ });
351
+ }
352
+ function durationToMilliseconds(duration) {
353
+ if (typeof duration === "string") {
354
+ if (typeof Temporal === "undefined") throw new TypeError("Temporal is not globally available; pass interval as a Temporal.DurationLike object instead of a string, or provide a Temporal polyfill.");
355
+ return Temporal.Duration.from(duration).total({ unit: "milliseconds" });
356
+ }
357
+ return (duration.milliseconds ?? 0) + (duration.seconds ?? 0) * 1e3 + (duration.minutes ?? 0) * 60 * 1e3 + (duration.hours ?? 0) * 60 * 60 * 1e3 + (duration.days ?? 0) * 24 * 60 * 60 * 1e3;
358
+ }
359
+ function isCollection(object) {
360
+ return object instanceof Collection || object instanceof OrderedCollection || object instanceof CollectionPage || object instanceof OrderedCollectionPage;
361
+ }
362
+ function isContextPostObject(object) {
363
+ return object instanceof Object$1 && !(object instanceof Activity) && !isCollection(object);
364
+ }
365
+ //#endregion
366
+ //#region src/backfill.test.ts
367
+ async function collect(context, note, options = {}) {
368
+ return await Array.fromAsync(backfill(context, note, options));
369
+ }
370
+ describe("backfill", () => {
371
+ test("package exports backfill", () => {
372
+ strictEqual(typeof backfill, "function");
373
+ strictEqual(typeof MaxRequestsExceeded, "function");
374
+ });
375
+ test("context missing yields nothing", async () => {
376
+ deepStrictEqual(await collect({ documentLoader: () => {
377
+ throw new Error("documentLoader should not be called");
378
+ } }, new Note({ id: new URL("https://example.com/notes/1") })), []);
379
+ });
380
+ test("context resolves to non-collection yields nothing", async () => {
381
+ const contextId = new URL("https://example.com/contexts/1");
382
+ deepStrictEqual(await collect({ documentLoader: () => Promise.resolve(new Note({ id: new URL("https://example.com/notes/2") })) }, new Note({
383
+ id: new URL("https://example.com/notes/1"),
384
+ contexts: [contextId]
385
+ })), []);
386
+ });
387
+ test("context collection with embedded objects yields items", async () => {
388
+ const contextId = new URL("https://example.com/contexts/1");
389
+ const item = new Note({
390
+ id: new URL("https://example.com/notes/2"),
391
+ content: "hello"
392
+ });
393
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
394
+ id: contextId,
395
+ items: [item]
396
+ })) }, new Note({
397
+ id: new URL("https://example.com/notes/1"),
398
+ contexts: [contextId]
399
+ }));
400
+ strictEqual(items.length, 1);
401
+ strictEqual(items[0].object, item);
402
+ deepStrictEqual(items[0].id, item.id);
403
+ strictEqual(items[0].strategy, "context-auto");
404
+ strictEqual(items[0].origin, "collection");
405
+ });
406
+ test("context object strategy yields embedded objects", async () => {
407
+ const contextId = new URL("https://example.com/contexts/1");
408
+ const item = new Note({
409
+ id: new URL("https://example.com/notes/2"),
410
+ content: "hello"
411
+ });
412
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
413
+ id: contextId,
414
+ items: [item]
415
+ })) }, new Note({
416
+ id: new URL("https://example.com/notes/1"),
417
+ contexts: [contextId]
418
+ }), { strategies: ["context-objects"] });
419
+ strictEqual(items.length, 1);
420
+ strictEqual(items[0].object, item);
421
+ strictEqual(items[0].strategy, "context-objects");
422
+ });
423
+ test("embedded object without id is yielded without id", async () => {
424
+ const contextId = new URL("https://example.com/contexts/1");
425
+ const item = new Note({ content: "anonymous" });
426
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
427
+ id: contextId,
428
+ items: [item]
429
+ })) }, new Note({
430
+ id: new URL("https://example.com/notes/1"),
431
+ contexts: [contextId]
432
+ }));
433
+ strictEqual(items.length, 1);
434
+ strictEqual(items[0].object, item);
435
+ strictEqual(items[0].id, void 0);
436
+ });
437
+ test("context object strategy skips activity objects", async () => {
438
+ const contextId = new URL("https://example.com/contexts/1");
439
+ const activity = new Create({
440
+ id: new URL("https://example.com/activities/1"),
441
+ object: new Note({ id: new URL("https://example.com/notes/2") })
442
+ });
443
+ deepStrictEqual(await collect({ documentLoader: () => Promise.resolve(new Collection({
444
+ id: contextId,
445
+ items: [activity]
446
+ })) }, new Note({
447
+ id: new URL("https://example.com/notes/1"),
448
+ contexts: [contextId]
449
+ }), { strategies: ["context-objects"] }), []);
450
+ });
451
+ test("context auto strategy yields object from embedded Create", async () => {
452
+ const contextId = new URL("https://example.com/contexts/1");
453
+ const item = new Note({
454
+ id: new URL("https://example.com/notes/2"),
455
+ content: "hello"
456
+ });
457
+ const activity = new Create({
458
+ id: new URL("https://example.com/activities/1"),
459
+ object: item
460
+ });
461
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
462
+ id: contextId,
463
+ items: [activity]
464
+ })) }, new Note({
465
+ id: new URL("https://example.com/notes/1"),
466
+ contexts: [contextId]
467
+ }));
468
+ strictEqual(items.length, 1);
469
+ strictEqual(items[0].object, item);
470
+ strictEqual(items[0].strategy, "context-auto");
471
+ });
472
+ test("empty strategies yield nothing without dereferencing context", async () => {
473
+ deepStrictEqual(await collect({ documentLoader: () => {
474
+ throw new Error("documentLoader should not be called");
475
+ } }, new Note({
476
+ id: new URL("https://example.com/notes/1"),
477
+ contexts: [new URL("https://example.com/contexts/1")]
478
+ }), { strategies: [] }), []);
479
+ });
480
+ test("reply tree strategy does not require context collection", async () => {
481
+ deepStrictEqual(await collect({ documentLoader: () => {
482
+ throw new Error("documentLoader should not be called");
483
+ } }, new Note({
484
+ id: new URL("https://example.com/notes/1"),
485
+ contexts: [new URL("https://example.com/contexts/1")]
486
+ }), { strategies: ["reply-tree"] }), []);
487
+ });
488
+ test("reply tree yields embedded ancestor", async () => {
489
+ const parent = new Note({
490
+ id: new URL("https://example.com/notes/1"),
491
+ content: "parent"
492
+ });
493
+ const items = await collect({ documentLoader: () => {
494
+ throw new Error("documentLoader should not be called");
495
+ } }, new Note({
496
+ id: new URL("https://example.com/notes/2"),
497
+ replyTarget: parent
498
+ }), { strategies: ["reply-tree"] });
499
+ strictEqual(items.length, 1);
500
+ strictEqual(items[0].object, parent);
501
+ deepStrictEqual(items[0].id, parent.id);
502
+ strictEqual(items[0].strategy, "reply-tree");
503
+ strictEqual(items[0].origin, "in-reply-to");
504
+ strictEqual(items[0].depth, 1);
505
+ });
506
+ test("reply tree dereferences ancestor URL", async () => {
507
+ const parentId = new URL("https://example.com/notes/1");
508
+ const parent = new Note({
509
+ id: parentId,
510
+ content: "parent"
511
+ });
512
+ const items = await collect({ documentLoader: (iri) => Promise.resolve(iri.href === parentId.href ? parent : null) }, new Note({
513
+ id: new URL("https://example.com/notes/2"),
514
+ replyTarget: parentId
515
+ }), { strategies: ["reply-tree"] });
516
+ strictEqual(items.length, 1);
517
+ deepStrictEqual(items[0].object.id, parent.id);
518
+ strictEqual(items[0].origin, "in-reply-to");
519
+ strictEqual(items[0].depth, 1);
520
+ });
521
+ test("reply tree maxDepth limits ancestors", async () => {
522
+ const rootId = new URL("https://example.com/notes/1");
523
+ const parentId = new URL("https://example.com/notes/2");
524
+ const root = new Note({
525
+ id: rootId,
526
+ content: "root"
527
+ });
528
+ const parent = new Note({
529
+ id: parentId,
530
+ content: "parent",
531
+ replyTarget: rootId
532
+ });
533
+ const items = await collect({ documentLoader: (iri) => {
534
+ if (iri.href === parentId.href) return Promise.resolve(parent);
535
+ if (iri.href === rootId.href) return Promise.resolve(root);
536
+ return Promise.resolve(null);
537
+ } }, new Note({
538
+ id: new URL("https://example.com/notes/3"),
539
+ replyTarget: parentId
540
+ }), {
541
+ strategies: ["reply-tree"],
542
+ maxDepth: 1
543
+ });
544
+ strictEqual(items.length, 1);
545
+ deepStrictEqual(items[0].object.id, parent.id);
546
+ strictEqual(items[0].depth, 1);
547
+ });
548
+ test("reply tree defaults maxDepth to 10 for ancestors", async () => {
549
+ let note = new Note({ id: new URL("https://example.com/notes/0") });
550
+ for (let i = 1; i <= 12; i++) note = new Note({
551
+ id: new URL(`https://example.com/notes/${i}`),
552
+ replyTarget: note
553
+ });
554
+ const items = await collect({ documentLoader: () => {
555
+ throw new Error("documentLoader should not be called");
556
+ } }, note, { strategies: ["reply-tree"] });
557
+ strictEqual(items.length, 10);
558
+ strictEqual(items.at(-1)?.depth, 10);
559
+ });
560
+ test("maxRequests limits reply tree ancestor dereferencing", async () => {
561
+ const parentId = new URL("https://example.com/notes/1");
562
+ deepStrictEqual(await collect({ documentLoader: () => {
563
+ throw new Error("documentLoader should not be called");
564
+ } }, new Note({
565
+ id: new URL("https://example.com/notes/2"),
566
+ replyTarget: parentId
567
+ }), {
568
+ strategies: ["reply-tree"],
569
+ maxRequests: 0
570
+ }), []);
571
+ });
572
+ test("reply tree avoids ancestor cycles", async () => {
573
+ const seedId = new URL("https://example.com/notes/1");
574
+ const parentId = new URL("https://example.com/notes/2");
575
+ const note = new Note({
576
+ id: seedId,
577
+ replyTarget: parentId
578
+ });
579
+ const parent = new Note({
580
+ id: parentId,
581
+ replyTarget: seedId
582
+ });
583
+ const items = await collect({ documentLoader: (iri) => {
584
+ if (iri.href === seedId.href) return Promise.resolve(note);
585
+ if (iri.href === parentId.href) return Promise.resolve(parent);
586
+ return Promise.resolve(null);
587
+ } }, note, { strategies: ["reply-tree"] });
588
+ strictEqual(items.length, 1);
589
+ deepStrictEqual(items[0].object.id, parent.id);
590
+ });
591
+ test("reply tree deduplicates ancestors from context collection", async () => {
592
+ const contextId = new URL("https://example.com/contexts/1");
593
+ const parentId = new URL("https://example.com/notes/1");
594
+ const parent = new Note({
595
+ id: parentId,
596
+ content: "parent"
597
+ });
598
+ const items = await collect({ documentLoader: (iri) => {
599
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
600
+ id: contextId,
601
+ items: [parent]
602
+ }));
603
+ if (iri.href === parentId.href) return Promise.resolve(parent);
604
+ return Promise.resolve(null);
605
+ } }, new Note({
606
+ id: new URL("https://example.com/notes/2"),
607
+ contexts: [contextId],
608
+ replyTarget: parentId
609
+ }), { strategies: ["context-auto", "reply-tree"] });
610
+ strictEqual(items.length, 1);
611
+ strictEqual(items[0].object, parent);
612
+ strictEqual(items[0].strategy, "context-auto");
613
+ });
614
+ test("document cache avoids duplicate dereferences across strategies", async () => {
615
+ const contextId = new URL("https://example.com/contexts/1");
616
+ const parentId = new URL("https://example.com/notes/1");
617
+ const parent = new Note({
618
+ id: parentId,
619
+ content: "parent"
620
+ });
621
+ const note = new Note({
622
+ id: new URL("https://example.com/notes/2"),
623
+ contexts: [contextId],
624
+ replyTarget: parentId
625
+ });
626
+ const requests = [];
627
+ const items = await collect({ documentLoader: (iri) => {
628
+ requests.push(iri);
629
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
630
+ id: contextId,
631
+ items: [parentId]
632
+ }));
633
+ if (iri.href === parentId.href) return Promise.resolve(parent);
634
+ return Promise.resolve(null);
635
+ } }, note, { strategies: ["context-auto", "reply-tree"] });
636
+ strictEqual(items.length, 1);
637
+ strictEqual(items[0].object.id?.href, parentId.href);
638
+ deepStrictEqual(requests.map((url) => url.href), [contextId.href, parentId.href]);
639
+ });
640
+ test("document cache does not keep failed dereferences", async () => {
641
+ const contextId = new URL("https://example.com/contexts/1");
642
+ const parentId = new URL("https://example.com/notes/1");
643
+ const parent = new Note({
644
+ id: parentId,
645
+ content: "parent"
646
+ });
647
+ const note = new Note({
648
+ id: new URL("https://example.com/notes/2"),
649
+ contexts: [contextId],
650
+ replyTarget: parentId
651
+ });
652
+ const requests = [];
653
+ let parentRequests = 0;
654
+ const items = await collect({ documentLoader: (iri) => {
655
+ requests.push(iri);
656
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
657
+ id: contextId,
658
+ items: [parentId]
659
+ }));
660
+ if (iri.href === parentId.href) {
661
+ parentRequests++;
662
+ if (parentRequests === 1) throw new Error("temporary failure");
663
+ return Promise.resolve(parent);
664
+ }
665
+ return Promise.resolve(null);
666
+ } }, note, { strategies: ["context-auto", "reply-tree"] });
667
+ strictEqual(items.length, 1);
668
+ strictEqual(items[0].object.id?.href, parentId.href);
669
+ deepStrictEqual(requests.map((url) => url.href), [
670
+ contextId.href,
671
+ parentId.href,
672
+ parentId.href
673
+ ]);
674
+ });
675
+ test("strategy order controls deduplicated item metadata", async () => {
676
+ const contextId = new URL("https://example.com/contexts/1");
677
+ const parentId = new URL("https://example.com/notes/1");
678
+ const parent = new Note({
679
+ id: parentId,
680
+ content: "parent"
681
+ });
682
+ const items = await collect({ documentLoader: (iri) => {
683
+ if (iri.href === parentId.href) return Promise.resolve(parent);
684
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
685
+ id: contextId,
686
+ items: [parent]
687
+ }));
688
+ return Promise.resolve(null);
689
+ } }, new Note({
690
+ id: new URL("https://example.com/notes/2"),
691
+ contexts: [contextId],
692
+ replyTarget: parentId
693
+ }), { strategies: ["reply-tree", "context-auto"] });
694
+ strictEqual(items.length, 1);
695
+ strictEqual(items[0].object.id?.href, parentId.href);
696
+ strictEqual(items[0].strategy, "reply-tree");
697
+ strictEqual(items[0].origin, "in-reply-to");
698
+ });
699
+ test("context auto preserves strategy order across reply tree", async () => {
700
+ const contextId = new URL("https://example.com/contexts/1");
701
+ const parentId = new URL("https://example.com/notes/1");
702
+ const parent = new Note({
703
+ id: parentId,
704
+ content: "parent"
705
+ });
706
+ const items = await collect({ documentLoader: (iri) => {
707
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
708
+ id: contextId,
709
+ items: [parent]
710
+ }));
711
+ if (iri.href === parentId.href) return Promise.resolve(parent);
712
+ return Promise.resolve(null);
713
+ } }, new Note({
714
+ id: new URL("https://example.com/notes/2"),
715
+ contexts: [contextId],
716
+ replyTarget: parentId
717
+ }), { strategies: [
718
+ "context-objects",
719
+ "reply-tree",
720
+ "context-auto"
721
+ ] });
722
+ strictEqual(items.length, 1);
723
+ strictEqual(items[0].object.id?.href, parentId.href);
724
+ strictEqual(items[0].strategy, "context-objects");
725
+ strictEqual(items[0].origin, "collection");
726
+ });
727
+ test("reply tree yields embedded descendants", async () => {
728
+ const reply = new Note({
729
+ id: new URL("https://example.com/notes/2"),
730
+ content: "reply"
731
+ });
732
+ const items = await collect({ documentLoader: () => {
733
+ throw new Error("documentLoader should not be called");
734
+ } }, new Note({
735
+ id: new URL("https://example.com/notes/1"),
736
+ replies: new Collection({
737
+ id: new URL("https://example.com/notes/1/replies"),
738
+ items: [reply]
739
+ })
740
+ }), { strategies: ["reply-tree"] });
741
+ strictEqual(items.length, 1);
742
+ strictEqual(items[0].object, reply);
743
+ deepStrictEqual(items[0].id, reply.id);
744
+ strictEqual(items[0].strategy, "reply-tree");
745
+ strictEqual(items[0].origin, "replies");
746
+ strictEqual(items[0].depth, 1);
747
+ });
748
+ test("reply tree walks sibling descendants from discovered ancestor", async () => {
749
+ const seedId = new URL("https://example.com/notes/2");
750
+ const sibling = new Note({
751
+ id: new URL("https://example.com/notes/3"),
752
+ content: "sibling"
753
+ });
754
+ const parent = new Note({
755
+ id: new URL("https://example.com/notes/1"),
756
+ content: "parent",
757
+ replies: new Collection({
758
+ id: new URL("https://example.com/notes/1/replies"),
759
+ items: [seedId, sibling]
760
+ })
761
+ });
762
+ const items = await collect({ documentLoader: () => {
763
+ throw new Error("documentLoader should not be called");
764
+ } }, new Note({
765
+ id: seedId,
766
+ replyTarget: parent
767
+ }), { strategies: ["reply-tree"] });
768
+ strictEqual(items.length, 2);
769
+ strictEqual(items[0].object, parent);
770
+ strictEqual(items[0].origin, "in-reply-to");
771
+ strictEqual(items[0].depth, 1);
772
+ strictEqual(items[1].object, sibling);
773
+ strictEqual(items[1].origin, "replies");
774
+ strictEqual(items[1].depth, 2);
775
+ });
776
+ test("reply tree maxDepth applies from seed through ancestors", async () => {
777
+ const seedId = new URL("https://example.com/notes/2");
778
+ const sibling = new Note({
779
+ id: new URL("https://example.com/notes/3"),
780
+ content: "sibling"
781
+ });
782
+ const parent = new Note({
783
+ id: new URL("https://example.com/notes/1"),
784
+ content: "parent",
785
+ replies: new Collection({
786
+ id: new URL("https://example.com/notes/1/replies"),
787
+ items: [seedId, sibling]
788
+ })
789
+ });
790
+ const items = await collect({ documentLoader: () => {
791
+ throw new Error("documentLoader should not be called");
792
+ } }, new Note({
793
+ id: seedId,
794
+ replyTarget: parent
795
+ }), {
796
+ strategies: ["reply-tree"],
797
+ maxDepth: 1
798
+ });
799
+ strictEqual(items.length, 1);
800
+ strictEqual(items[0].object, parent);
801
+ strictEqual(items[0].depth, 1);
802
+ });
803
+ test("reply tree dereferences replies collection URL", async () => {
804
+ const repliesId = new URL("https://example.com/notes/1/replies");
805
+ const reply = new Note({
806
+ id: new URL("https://example.com/notes/2"),
807
+ content: "reply"
808
+ });
809
+ const items = await collect({ documentLoader: (iri) => Promise.resolve(iri.href === repliesId.href ? new Collection({
810
+ id: repliesId,
811
+ items: [reply]
812
+ }) : null) }, new Note({
813
+ id: new URL("https://example.com/notes/1"),
814
+ replies: repliesId
815
+ }), { strategies: ["reply-tree"] });
816
+ strictEqual(items.length, 1);
817
+ deepStrictEqual(items[0].object.id, reply.id);
818
+ strictEqual(items[0].origin, "replies");
819
+ strictEqual(items[0].depth, 1);
820
+ });
821
+ test("reply tree maxDepth limits descendants", async () => {
822
+ const grandchild = new Note({
823
+ id: new URL("https://example.com/notes/3"),
824
+ content: "grandchild"
825
+ });
826
+ const reply = new Note({
827
+ id: new URL("https://example.com/notes/2"),
828
+ content: "reply",
829
+ replies: new Collection({
830
+ id: new URL("https://example.com/notes/2/replies"),
831
+ items: [grandchild]
832
+ })
833
+ });
834
+ const items = await collect({ documentLoader: () => {
835
+ throw new Error("documentLoader should not be called");
836
+ } }, new Note({
837
+ id: new URL("https://example.com/notes/1"),
838
+ replies: new Collection({
839
+ id: new URL("https://example.com/notes/1/replies"),
840
+ items: [reply]
841
+ })
842
+ }), {
843
+ strategies: ["reply-tree"],
844
+ maxDepth: 1
845
+ });
846
+ strictEqual(items.length, 1);
847
+ strictEqual(items[0].object, reply);
848
+ strictEqual(items[0].depth, 1);
849
+ });
850
+ test("reply tree defaults maxDepth to 10 for descendants", async () => {
851
+ let note = new Note({ id: new URL("https://example.com/notes/12") });
852
+ for (let i = 11; i >= 0; i--) note = new Note({
853
+ id: new URL(`https://example.com/notes/${i}`),
854
+ replies: new Collection({
855
+ id: new URL(`https://example.com/notes/${i}/replies`),
856
+ items: [note]
857
+ })
858
+ });
859
+ const items = await collect({ documentLoader: () => {
860
+ throw new Error("documentLoader should not be called");
861
+ } }, note, { strategies: ["reply-tree"] });
862
+ strictEqual(items.length, 10);
863
+ strictEqual(items.at(-1)?.depth, 10);
864
+ });
865
+ test("maxRequests limits reply tree replies dereferencing", async () => {
866
+ const repliesId = new URL("https://example.com/notes/1/replies");
867
+ deepStrictEqual(await collect({ documentLoader: () => {
868
+ throw new Error("documentLoader should not be called");
869
+ } }, new Note({
870
+ id: new URL("https://example.com/notes/1"),
871
+ replies: repliesId
872
+ }), {
873
+ strategies: ["reply-tree"],
874
+ maxRequests: 0
875
+ }), []);
876
+ });
877
+ test("reply tree does not reload visited replies collection URL", async () => {
878
+ const repliesId = new URL("https://example.com/notes/1/replies");
879
+ const reply = new Note({
880
+ id: new URL("https://example.com/notes/2"),
881
+ content: "reply",
882
+ replies: repliesId
883
+ });
884
+ const note = new Note({
885
+ id: new URL("https://example.com/notes/1"),
886
+ replies: repliesId
887
+ });
888
+ let requests = 0;
889
+ const items = await collect({ documentLoader: (iri) => {
890
+ requests++;
891
+ strictEqual(iri.href, repliesId.href);
892
+ return Promise.resolve(new Collection({
893
+ id: repliesId,
894
+ items: [reply]
895
+ }));
896
+ } }, note, { strategies: ["reply-tree"] });
897
+ strictEqual(requests, 1);
898
+ strictEqual(items.length, 1);
899
+ strictEqual(items[0].object.id?.href, reply.id?.href);
900
+ });
901
+ test("reply tree retries a replies collection after load failure", async () => {
902
+ const repliesId = new URL("https://example.com/replies/shared");
903
+ const grandchild = new Note({
904
+ id: new URL("https://example.com/notes/4"),
905
+ content: "grandchild"
906
+ });
907
+ const first = new Note({
908
+ id: new URL("https://example.com/notes/2"),
909
+ replies: repliesId
910
+ });
911
+ const second = new Note({
912
+ id: new URL("https://example.com/notes/3"),
913
+ replies: repliesId
914
+ });
915
+ const note = new Note({
916
+ id: new URL("https://example.com/notes/1"),
917
+ replies: new Collection({
918
+ id: new URL("https://example.com/notes/1/replies"),
919
+ items: [first, second]
920
+ })
921
+ });
922
+ let requests = 0;
923
+ const items = await collect({ documentLoader: (iri) => {
924
+ strictEqual(iri.href, repliesId.href);
925
+ requests++;
926
+ if (requests === 1) throw new Error("temporary failure");
927
+ return Promise.resolve(new Collection({
928
+ id: repliesId,
929
+ items: [grandchild]
930
+ }));
931
+ } }, note, { strategies: ["reply-tree"] });
932
+ strictEqual(requests, 2);
933
+ deepStrictEqual(items.map((item) => item.object.id?.href), [
934
+ first.id?.href,
935
+ second.id?.href,
936
+ grandchild.id?.href
937
+ ]);
938
+ strictEqual(items[2].depth, 2);
939
+ });
940
+ test("reply tree skips visited reply IRIs before dereferencing", async () => {
941
+ const seedId = new URL("https://example.com/notes/1");
942
+ const siblingId = new URL("https://example.com/notes/2");
943
+ const sibling = new Note({
944
+ id: siblingId,
945
+ content: "sibling"
946
+ });
947
+ const note = new Note({
948
+ id: seedId,
949
+ replies: new Collection({
950
+ id: new URL("https://example.com/notes/1/replies"),
951
+ items: [seedId, siblingId]
952
+ })
953
+ });
954
+ const requests = [];
955
+ const items = await collect({ documentLoader: (iri) => {
956
+ requests.push(iri.href);
957
+ if (iri.href === siblingId.href) return Promise.resolve(sibling);
958
+ if (iri.href === seedId.href) throw new Error("seed should have been skipped");
959
+ return Promise.resolve(null);
960
+ } }, note, {
961
+ strategies: ["reply-tree"],
962
+ maxRequests: 1
963
+ });
964
+ deepStrictEqual(requests, [siblingId.href]);
965
+ strictEqual(items.length, 1);
966
+ strictEqual(items[0].object.id?.href, siblingId.href);
967
+ });
968
+ test("reply tree avoids descendant cycles", async () => {
969
+ const seedId = new URL("https://example.com/notes/1");
970
+ const replyId = new URL("https://example.com/notes/2");
971
+ const note = new Note({ id: seedId });
972
+ const reply = new Note({
973
+ id: replyId,
974
+ replies: new Collection({
975
+ id: new URL("https://example.com/notes/2/replies"),
976
+ items: [note]
977
+ })
978
+ });
979
+ const items = await collect({ documentLoader: () => {
980
+ throw new Error("documentLoader should not be called");
981
+ } }, note.clone({ replies: new Collection({
982
+ id: new URL("https://example.com/notes/1/replies"),
983
+ items: [reply]
984
+ }) }), { strategies: ["reply-tree"] });
985
+ strictEqual(items.length, 1);
986
+ strictEqual(items[0].object, reply);
987
+ });
988
+ test("context auto overrides overlapping context strategies", async () => {
989
+ const contextId = new URL("https://example.com/contexts/1");
990
+ const item = new Note({ content: "anonymous" });
991
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
992
+ id: contextId,
993
+ items: [item]
994
+ })) }, new Note({
995
+ id: new URL("https://example.com/notes/1"),
996
+ contexts: [contextId]
997
+ }), { strategies: [
998
+ "context-objects",
999
+ "context-auto",
1000
+ "reply-tree"
1001
+ ] });
1002
+ strictEqual(items.length, 1);
1003
+ strictEqual(items[0].object, item);
1004
+ strictEqual(items[0].strategy, "context-auto");
1005
+ });
1006
+ test("duplicate strategies are ignored", async () => {
1007
+ const contextId = new URL("https://example.com/contexts/1");
1008
+ const item = new Note({ content: "anonymous" });
1009
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
1010
+ id: contextId,
1011
+ items: [item]
1012
+ })) }, new Note({
1013
+ id: new URL("https://example.com/notes/1"),
1014
+ contexts: [contextId]
1015
+ }), { strategies: ["context-objects", "context-objects"] });
1016
+ strictEqual(items.length, 1);
1017
+ strictEqual(items[0].object, item);
1018
+ strictEqual(items[0].strategy, "context-objects");
1019
+ });
1020
+ test("context activity collection yields object from embedded Create", async () => {
1021
+ const contextId = new URL("https://example.com/contexts/1");
1022
+ const item = new Note({
1023
+ id: new URL("https://example.com/notes/2"),
1024
+ content: "hello"
1025
+ });
1026
+ const activity = new Create({
1027
+ id: new URL("https://example.com/activities/1"),
1028
+ object: item
1029
+ });
1030
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
1031
+ id: contextId,
1032
+ items: [activity]
1033
+ })) }, new Note({
1034
+ id: new URL("https://example.com/notes/1"),
1035
+ contexts: [contextId]
1036
+ }), { strategies: ["context-activities"] });
1037
+ strictEqual(items.length, 1);
1038
+ strictEqual(items[0].object, item);
1039
+ strictEqual(items[0].id?.href, item.id?.href);
1040
+ strictEqual(items[0].strategy, "context-activities");
1041
+ strictEqual(items[0].origin, "collection");
1042
+ });
1043
+ test("combined context strategies yield posts and activity objects", async () => {
1044
+ const contextId = new URL("https://example.com/contexts/1");
1045
+ const post = new Note({ id: new URL("https://example.com/notes/2") });
1046
+ const activityObject = new Note({ id: new URL("https://example.com/notes/3") });
1047
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
1048
+ id: contextId,
1049
+ items: [post, new Create({
1050
+ id: new URL("https://example.com/activities/1"),
1051
+ object: activityObject
1052
+ })]
1053
+ })) }, new Note({
1054
+ id: new URL("https://example.com/notes/1"),
1055
+ contexts: [contextId]
1056
+ }), { strategies: ["context-objects", "context-activities"] });
1057
+ strictEqual(items.length, 2);
1058
+ strictEqual(items[0].object, post);
1059
+ strictEqual(items[0].strategy, "context-objects");
1060
+ strictEqual(items[1].object, activityObject);
1061
+ strictEqual(items[1].strategy, "context-activities");
1062
+ });
1063
+ test("combined context strategies share context collection loading", async () => {
1064
+ const contextId = new URL("https://example.com/contexts/1");
1065
+ const post = new Note({
1066
+ id: new URL("https://example.com/notes/2"),
1067
+ content: "hello"
1068
+ });
1069
+ const activityObject = new Note({
1070
+ id: new URL("https://example.com/notes/3"),
1071
+ content: "activity object"
1072
+ });
1073
+ const activity = new Create({
1074
+ id: new URL("https://example.com/activities/1"),
1075
+ object: activityObject
1076
+ });
1077
+ const note = new Note({
1078
+ id: new URL("https://example.com/notes/1"),
1079
+ contexts: [contextId]
1080
+ });
1081
+ let requests = 0;
1082
+ const items = await collect({ documentLoader: (iri) => {
1083
+ requests++;
1084
+ strictEqual(iri.href, contextId.href);
1085
+ return Promise.resolve(new Collection({
1086
+ id: contextId,
1087
+ items: [post, activity]
1088
+ }));
1089
+ } }, note, { strategies: ["context-objects", "context-activities"] });
1090
+ strictEqual(requests, 1);
1091
+ strictEqual(items.length, 2);
1092
+ strictEqual(items[0].object, post);
1093
+ strictEqual(items[1].object, activityObject);
1094
+ });
1095
+ test("context activity collection dereferences activity object URL", async () => {
1096
+ const contextId = new URL("https://example.com/contexts/1");
1097
+ const itemId = new URL("https://example.com/notes/2");
1098
+ const item = new Note({
1099
+ id: itemId,
1100
+ content: "hello"
1101
+ });
1102
+ const activity = new Create({
1103
+ id: new URL("https://example.com/activities/1"),
1104
+ object: itemId
1105
+ });
1106
+ const note = new Note({
1107
+ id: new URL("https://example.com/notes/1"),
1108
+ contexts: [contextId]
1109
+ });
1110
+ const requests = [];
1111
+ const items = await collect({ documentLoader: (iri) => {
1112
+ requests.push(iri);
1113
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
1114
+ id: contextId,
1115
+ items: [activity]
1116
+ }));
1117
+ if (iri.href === itemId.href) return Promise.resolve(item);
1118
+ return Promise.resolve(null);
1119
+ } }, note, { strategies: ["context-activities"] });
1120
+ strictEqual(items.length, 1);
1121
+ strictEqual(items[0].object.id?.href, item.id?.href);
1122
+ deepStrictEqual(requests.map((url) => url.href), [contextId.href, itemId.href]);
1123
+ });
1124
+ test("context activity collection dereferences activity URL", async () => {
1125
+ const contextId = new URL("https://example.com/contexts/1");
1126
+ const activityId = new URL("https://example.com/activities/1");
1127
+ const item = new Note({
1128
+ id: new URL("https://example.com/notes/2"),
1129
+ content: "hello"
1130
+ });
1131
+ const activity = new Create({
1132
+ id: activityId,
1133
+ object: item
1134
+ });
1135
+ const note = new Note({
1136
+ id: new URL("https://example.com/notes/1"),
1137
+ contexts: [contextId]
1138
+ });
1139
+ const requests = [];
1140
+ const items = await collect({ documentLoader: (iri) => {
1141
+ requests.push(iri);
1142
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
1143
+ id: contextId,
1144
+ items: [activityId]
1145
+ }));
1146
+ if (iri.href === activityId.href) return Promise.resolve(activity);
1147
+ return Promise.resolve(null);
1148
+ } }, note, { strategies: ["context-activities"] });
1149
+ strictEqual(items.length, 1);
1150
+ strictEqual(items[0].object.id?.href, item.id?.href);
1151
+ deepStrictEqual(requests.map((url) => url.href), [contextId.href, activityId.href]);
1152
+ });
1153
+ test("context activity collection deduplicates by extracted object ID", async () => {
1154
+ const contextId = new URL("https://example.com/contexts/1");
1155
+ const itemId = new URL("https://example.com/notes/2");
1156
+ const first = new Create({
1157
+ id: new URL("https://example.com/activities/1"),
1158
+ object: new Note({
1159
+ id: itemId,
1160
+ content: "first"
1161
+ })
1162
+ });
1163
+ const second = new Create({
1164
+ id: new URL("https://example.com/activities/2"),
1165
+ object: new Note({
1166
+ id: itemId,
1167
+ content: "second"
1168
+ })
1169
+ });
1170
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
1171
+ id: contextId,
1172
+ items: [first, second]
1173
+ })) }, new Note({
1174
+ id: new URL("https://example.com/notes/1"),
1175
+ contexts: [contextId]
1176
+ }), { strategies: ["context-activities"] });
1177
+ strictEqual(items.length, 1);
1178
+ strictEqual(items[0].id?.href, itemId.href);
1179
+ });
1180
+ test("context activity collection skips missing object", async () => {
1181
+ const contextId = new URL("https://example.com/contexts/1");
1182
+ const activity = new Create({ id: new URL("https://example.com/activities/1") });
1183
+ deepStrictEqual(await collect({ documentLoader: () => Promise.resolve(new Collection({
1184
+ id: contextId,
1185
+ items: [activity]
1186
+ })) }, new Note({
1187
+ id: new URL("https://example.com/notes/1"),
1188
+ contexts: [contextId]
1189
+ }), { strategies: ["context-activities"] }), []);
1190
+ });
1191
+ test("context activity collection skips unsupported activity type", async () => {
1192
+ const contextId = new URL("https://example.com/contexts/1");
1193
+ const item = new Note({ id: new URL("https://example.com/notes/2") });
1194
+ const activity = new Announce({
1195
+ id: new URL("https://example.com/activities/1"),
1196
+ object: item
1197
+ });
1198
+ deepStrictEqual(await collect({ documentLoader: () => Promise.resolve(new Collection({
1199
+ id: contextId,
1200
+ items: [activity]
1201
+ })) }, new Note({
1202
+ id: new URL("https://example.com/notes/1"),
1203
+ contexts: [contextId]
1204
+ }), { strategies: ["context-activities"] }), []);
1205
+ });
1206
+ test("maxRequests limits activity object dereferencing", async () => {
1207
+ const contextId = new URL("https://example.com/contexts/1");
1208
+ const activityId = new URL("https://example.com/activities/1");
1209
+ const itemId = new URL("https://example.com/notes/2");
1210
+ const activity = new Create({
1211
+ id: activityId,
1212
+ object: itemId
1213
+ });
1214
+ const note = new Note({
1215
+ id: new URL("https://example.com/notes/1"),
1216
+ contexts: [contextId]
1217
+ });
1218
+ const requests = [];
1219
+ deepStrictEqual(await collect({ documentLoader: (iri) => {
1220
+ requests.push(iri);
1221
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
1222
+ id: contextId,
1223
+ items: [activityId]
1224
+ }));
1225
+ if (iri.href === activityId.href) return Promise.resolve(activity);
1226
+ if (iri.href === itemId.href) return Promise.resolve(new Note({ id: itemId }));
1227
+ return Promise.resolve(null);
1228
+ } }, note, {
1229
+ maxRequests: 2,
1230
+ strategies: ["context-activities"]
1231
+ }), []);
1232
+ deepStrictEqual(requests.map((url) => url.href), [contextId.href, activityId.href]);
1233
+ });
1234
+ test("maxItems limits context activity items", async () => {
1235
+ const contextId = new URL("https://example.com/contexts/1");
1236
+ const first = new Note({ id: new URL("https://example.com/notes/2") });
1237
+ const second = new Note({ id: new URL("https://example.com/notes/3") });
1238
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
1239
+ id: contextId,
1240
+ items: [new Create({
1241
+ id: new URL("https://example.com/activities/1"),
1242
+ object: first
1243
+ }), new Create({
1244
+ id: new URL("https://example.com/activities/2"),
1245
+ object: second
1246
+ })]
1247
+ })) }, new Note({
1248
+ id: new URL("https://example.com/notes/1"),
1249
+ contexts: [contextId]
1250
+ }), {
1251
+ maxItems: 1,
1252
+ strategies: ["context-activities"]
1253
+ });
1254
+ strictEqual(items.length, 1);
1255
+ strictEqual(items[0].id?.href, first.id?.href);
1256
+ });
1257
+ test("context collection with URL items loads and yields objects", async () => {
1258
+ const contextId = new URL("https://example.com/contexts/1");
1259
+ const itemId = new URL("https://example.com/notes/2");
1260
+ const item = new Note({
1261
+ id: itemId,
1262
+ content: "hello"
1263
+ });
1264
+ const note = new Note({
1265
+ id: new URL("https://example.com/notes/1"),
1266
+ contexts: [contextId]
1267
+ });
1268
+ const requests = [];
1269
+ const items = await collect({ documentLoader: (iri) => {
1270
+ requests.push(iri);
1271
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
1272
+ id: contextId,
1273
+ items: [itemId]
1274
+ }));
1275
+ if (iri.href === itemId.href) return Promise.resolve(item);
1276
+ return Promise.resolve(null);
1277
+ } }, note);
1278
+ strictEqual(items.length, 1);
1279
+ ok(items[0].id instanceof URL);
1280
+ strictEqual(items[0].id.href, itemId.href);
1281
+ deepStrictEqual(requests.map((url) => url.href), [contextId.href, itemId.href]);
1282
+ });
1283
+ test("seen context collection URL items are not loaded", async () => {
1284
+ const contextId = new URL("https://example.com/contexts/1");
1285
+ const seedId = new URL("https://example.com/notes/1");
1286
+ const itemId = new URL("https://example.com/notes/2");
1287
+ const item = new Note({
1288
+ id: itemId,
1289
+ content: "hello"
1290
+ });
1291
+ const note = new Note({
1292
+ id: seedId,
1293
+ contexts: [contextId]
1294
+ });
1295
+ const requests = [];
1296
+ const items = await collect({ documentLoader: (iri) => {
1297
+ requests.push(iri);
1298
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
1299
+ id: contextId,
1300
+ items: [seedId, itemId]
1301
+ }));
1302
+ if (iri.href === itemId.href) return Promise.resolve(item);
1303
+ throw new Error("seen collection item should not be loaded");
1304
+ } }, note);
1305
+ strictEqual(items.length, 1);
1306
+ strictEqual(items[0].id?.href, itemId.href);
1307
+ deepStrictEqual(requests.map((url) => url.href), [contextId.href, itemId.href]);
1308
+ });
1309
+ test("failed URL collection items are skipped", async () => {
1310
+ const contextId = new URL("https://example.com/contexts/1");
1311
+ const missingItemId = new URL("https://example.com/notes/missing");
1312
+ const failedItemId = new URL("https://example.com/notes/failed");
1313
+ const itemId = new URL("https://example.com/notes/2");
1314
+ const item = new Note({
1315
+ id: itemId,
1316
+ content: "hello"
1317
+ });
1318
+ const items = await collect({ documentLoader: (iri) => {
1319
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
1320
+ id: contextId,
1321
+ items: [
1322
+ missingItemId,
1323
+ failedItemId,
1324
+ itemId
1325
+ ]
1326
+ }));
1327
+ if (iri.href === missingItemId.href) return Promise.resolve(null);
1328
+ if (iri.href === failedItemId.href) return Promise.reject(/* @__PURE__ */ new Error("failed to load"));
1329
+ if (iri.href === itemId.href) return Promise.resolve(item);
1330
+ return Promise.resolve(null);
1331
+ } }, new Note({
1332
+ id: new URL("https://example.com/notes/1"),
1333
+ contexts: [contextId]
1334
+ }));
1335
+ strictEqual(items.length, 1);
1336
+ strictEqual(items[0].id?.href, itemId.href);
1337
+ });
1338
+ test("seed is not yielded again when present in collection", async () => {
1339
+ const contextId = new URL("https://example.com/contexts/1");
1340
+ const note = new Note({
1341
+ id: new URL("https://example.com/notes/1"),
1342
+ contexts: [contextId]
1343
+ });
1344
+ const other = new Note({ id: new URL("https://example.com/notes/2") });
1345
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
1346
+ id: contextId,
1347
+ items: [note, other]
1348
+ })) }, note);
1349
+ strictEqual(items.length, 1);
1350
+ strictEqual(items[0].object, other);
1351
+ });
1352
+ test("duplicate object IDs are skipped", async () => {
1353
+ const contextId = new URL("https://example.com/contexts/1");
1354
+ const duplicateId = new URL("https://example.com/notes/2");
1355
+ const first = new Note({
1356
+ id: duplicateId,
1357
+ content: "first"
1358
+ });
1359
+ const second = new Note({
1360
+ id: duplicateId,
1361
+ content: "second"
1362
+ });
1363
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
1364
+ id: contextId,
1365
+ items: [first, second]
1366
+ })) }, new Note({
1367
+ id: new URL("https://example.com/notes/1"),
1368
+ contexts: [contextId]
1369
+ }));
1370
+ strictEqual(items.length, 1);
1371
+ strictEqual(items[0].object, first);
1372
+ });
1373
+ test("maxItems limits yielded items", async () => {
1374
+ const contextId = new URL("https://example.com/contexts/1");
1375
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
1376
+ id: contextId,
1377
+ items: [new Note({ id: new URL("https://example.com/notes/2") }), new Note({ id: new URL("https://example.com/notes/3") })]
1378
+ })) }, new Note({
1379
+ id: new URL("https://example.com/notes/1"),
1380
+ contexts: [contextId]
1381
+ }), { maxItems: 1 });
1382
+ strictEqual(items.length, 1);
1383
+ strictEqual(items[0].id?.href, "https://example.com/notes/2");
1384
+ });
1385
+ test("maxItems is shared across context and reply tree", async () => {
1386
+ const contextId = new URL("https://example.com/contexts/1");
1387
+ const reply = new Note({
1388
+ id: new URL("https://example.com/notes/3"),
1389
+ content: "reply"
1390
+ });
1391
+ const note = new Note({
1392
+ id: new URL("https://example.com/notes/1"),
1393
+ contexts: [contextId],
1394
+ replies: new Collection({
1395
+ id: new URL("https://example.com/notes/1/replies"),
1396
+ items: [reply]
1397
+ })
1398
+ });
1399
+ const contextItem = new Note({
1400
+ id: new URL("https://example.com/notes/2"),
1401
+ content: "context item"
1402
+ });
1403
+ const items = await collect({ documentLoader: () => Promise.resolve(new Collection({
1404
+ id: contextId,
1405
+ items: [contextItem]
1406
+ })) }, note, {
1407
+ strategies: ["context-auto", "reply-tree"],
1408
+ maxItems: 1
1409
+ });
1410
+ strictEqual(items.length, 1);
1411
+ strictEqual(items[0].object, contextItem);
1412
+ strictEqual(items[0].strategy, "context-auto");
1413
+ });
1414
+ test("maxRequests limits dereferencing", async () => {
1415
+ const contextId = new URL("https://example.com/contexts/1");
1416
+ const itemId = new URL("https://example.com/notes/2");
1417
+ deepStrictEqual(await collect({ documentLoader: (iri) => {
1418
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
1419
+ id: contextId,
1420
+ items: [itemId]
1421
+ }));
1422
+ return Promise.resolve(new Note({ id: iri }));
1423
+ } }, new Note({
1424
+ id: new URL("https://example.com/notes/1"),
1425
+ contexts: [contextId]
1426
+ }), { maxRequests: 1 }), []);
1427
+ });
1428
+ test("maxRequests is shared across context and reply tree", async () => {
1429
+ const contextId = new URL("https://example.com/contexts/1");
1430
+ const parentId = new URL("https://example.com/notes/0");
1431
+ const note = new Note({
1432
+ id: new URL("https://example.com/notes/1"),
1433
+ contexts: [contextId],
1434
+ replyTarget: parentId
1435
+ });
1436
+ const contextItem = new Note({
1437
+ id: new URL("https://example.com/notes/2"),
1438
+ content: "context item"
1439
+ });
1440
+ const items = await collect({ documentLoader: (iri) => {
1441
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
1442
+ id: contextId,
1443
+ items: [contextItem]
1444
+ }));
1445
+ throw new Error("reply-tree request should be budgeted out");
1446
+ } }, note, {
1447
+ strategies: ["context-auto", "reply-tree"],
1448
+ maxRequests: 1
1449
+ });
1450
+ strictEqual(items.length, 1);
1451
+ strictEqual(items[0].object, contextItem);
1452
+ });
1453
+ test("AbortSignal stops traversal", async () => {
1454
+ const contextId = new URL("https://example.com/contexts/1");
1455
+ const note = new Note({
1456
+ id: new URL("https://example.com/notes/1"),
1457
+ contexts: [contextId]
1458
+ });
1459
+ const controller = new AbortController();
1460
+ controller.abort();
1461
+ await rejects(collect({ documentLoader: () => Promise.resolve(new Collection({
1462
+ id: contextId,
1463
+ items: [new Note({ id: new URL("https://example.com/notes/2") })]
1464
+ })) }, note, { signal: controller.signal }), { name: "AbortError" });
1465
+ });
1466
+ test("AbortSignal stops traversal across strategies", async () => {
1467
+ const contextId = new URL("https://example.com/contexts/1");
1468
+ const parentId = new URL("https://example.com/notes/0");
1469
+ const controller = new AbortController();
1470
+ const note = new Note({
1471
+ id: new URL("https://example.com/notes/1"),
1472
+ contexts: [contextId],
1473
+ replyTarget: parentId
1474
+ });
1475
+ const contextItem = new Note({
1476
+ id: new URL("https://example.com/notes/2"),
1477
+ content: "context item"
1478
+ });
1479
+ let requests = 0;
1480
+ const context = { documentLoader: (iri) => {
1481
+ requests++;
1482
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
1483
+ id: contextId,
1484
+ items: [contextItem]
1485
+ }));
1486
+ throw new Error("reply-tree request should not be started");
1487
+ } };
1488
+ const items = [];
1489
+ await rejects(async () => {
1490
+ for await (const item of backfill(context, note, {
1491
+ strategies: ["context-auto", "reply-tree"],
1492
+ signal: controller.signal
1493
+ })) {
1494
+ items.push(item);
1495
+ controller.abort();
1496
+ }
1497
+ }, { name: "AbortError" });
1498
+ strictEqual(requests, 1);
1499
+ strictEqual(items.length, 1);
1500
+ strictEqual(items[0].object, contextItem);
1501
+ });
1502
+ test("documentLoader receives AbortSignal", async () => {
1503
+ const contextId = new URL("https://example.com/contexts/1");
1504
+ const note = new Note({
1505
+ id: new URL("https://example.com/notes/1"),
1506
+ contexts: [contextId]
1507
+ });
1508
+ const controller = new AbortController();
1509
+ let receivedSignal;
1510
+ await collect({ documentLoader: (_iri, options) => {
1511
+ receivedSignal = options?.signal;
1512
+ return Promise.resolve(new Collection({
1513
+ id: contextId,
1514
+ items: []
1515
+ }));
1516
+ } }, note, { signal: controller.signal });
1517
+ strictEqual(receivedSignal, controller.signal);
1518
+ });
1519
+ test("interval callback receives zero-based request index", async () => {
1520
+ const contextId = new URL("https://example.com/contexts/1");
1521
+ const itemId = new URL("https://example.com/notes/2");
1522
+ const note = new Note({
1523
+ id: new URL("https://example.com/notes/1"),
1524
+ contexts: [contextId]
1525
+ });
1526
+ const iterations = [];
1527
+ await collect({ documentLoader: (iri) => {
1528
+ if (iri.href === contextId.href) return Promise.resolve(new Collection({
1529
+ id: contextId,
1530
+ items: [itemId]
1531
+ }));
1532
+ return Promise.resolve(new Note({ id: iri }));
1533
+ } }, note, { interval: (iteration) => {
1534
+ iterations.push(iteration);
1535
+ return { milliseconds: 0 };
1536
+ } });
1537
+ deepStrictEqual(iterations, [0, 1]);
1538
+ });
1539
+ });
1540
+ //#endregion
1541
+ export {};