@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.
- package/LICENSE +20 -0
- package/README.md +152 -0
- package/dist/backfill.test.cjs +1563 -0
- package/dist/backfill.test.d.cts +1 -0
- package/dist/backfill.test.d.ts +1 -0
- package/dist/backfill.test.js +1541 -0
- package/dist/mod.cjs +366 -0
- package/dist/mod.d.cts +157 -0
- package/dist/mod.d.ts +157 -0
- package/dist/mod.js +364 -0
- package/package.json +67 -0
|
@@ -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 {};
|