From 1e7a85372bb145c341a865b6e96dcda08a59762b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 15 Jun 2026 14:28:22 +0900 Subject: [PATCH 01/17] Add reply-tree API contract Extend the backfill public strategy and origin types for the upcoming reply-tree traversal. Clarify that context-auto absorbs only other context collection strategies so it can later compose with reply-tree traversal. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/types.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts index ae6d264ba..72612ad15 100644 --- a/packages/backfill/src/types.ts +++ b/packages/backfill/src/types.ts @@ -9,21 +9,28 @@ import type { Object as APObject } from "@fedify/vocab"; * activities in the context collection. * - `"context-auto"` classifies context collection items automatically, * handling direct post-like objects and supported `Create` activities. - * If included, it absorbs all other strategies. + * If included, it absorbs other context collection strategies. + * - `"reply-tree"` walks the reply graph through `inReplyTo` ancestors and + * `replies` descendants. * * @since 2.x.0 */ export type BackfillStrategy = | "context-objects" | "context-activities" - | "context-auto"; + | "context-auto" + | "reply-tree"; /** * Source relation that produced a backfilled object. * * @since 2.x.0 */ -export type BackfillOrigin = "context" | "collection"; +export type BackfillOrigin = + | "context" + | "collection" + | "in-reply-to" + | "replies"; /** * Options passed to {@link BackfillDocumentLoader}. @@ -71,7 +78,8 @@ export interface BackfillOptions< * Backfill strategies to run. * * Defaults to `["context-auto"]`. - * If `"context-auto"` is included, it absorbs all other strategies. + * If `"context-auto"` is included, it absorbs other context collection + * strategies. * * @since 2.x.0 */ From a99e8c65f81aeba0e664a0646d449e7fc3a7a044 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 15 Jun 2026 14:37:32 +0900 Subject: [PATCH 02/17] Refactor backfill strategy orchestration Run normalized strategies through a shared orchestration path so context collection traversal can compose with the upcoming reply-tree strategy. Keep context-auto absorbing only overlapping context collection strategies, while preserving global deduplication, budgets, and yield metadata handling. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/backfill.test.ts | 21 +++- packages/backfill/src/backfill.ts | 133 ++++++++++++++++++------- 2 files changed, 115 insertions(+), 39 deletions(-) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index 12dc0928d..66057a06b 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -201,7 +201,24 @@ describe("backfill", () => { deepStrictEqual(await collect(context, note, { strategies: [] }), []); }); - test("context auto overrides overlapping strategies", async () => { + test("reply tree strategy does not require context collection", async () => { + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [new URL("https://example.com/contexts/1")], + }); + const context: BackfillContext = { + documentLoader: () => { + throw new Error("documentLoader should not be called"); + }, + }; + + deepStrictEqual( + await collect(context, note, { strategies: ["reply-tree"] }), + [], + ); + }); + + test("context auto overrides overlapping context strategies", async () => { const contextId = new URL("https://example.com/contexts/1"); const item = new Note({ content: "anonymous" }); const note = new Note({ @@ -219,7 +236,7 @@ describe("backfill", () => { }; const items = await collect(context, note, { - strategies: ["context-auto", "context-objects"], + strategies: ["context-objects", "context-auto", "reply-tree"], }); strictEqual(items.length, 1); diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index 3a0b4361d..308ac554f 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -13,6 +13,7 @@ import type { BackfillContext, BackfillItem, BackfillOptions, + BackfillOrigin, BackfillStrategy, } from "./types.ts"; @@ -51,9 +52,6 @@ export async function* backfill< const strategies = normalizeStrategies(options.strategies); if (strategies.length < 1) return; - const contextId = note.contextIds[0]; - if (contextId == null) return; - const budget: RequestBudget = { signal: options.signal, requestCount: 0, @@ -61,19 +59,14 @@ export async function* backfill< const seenIds = new Set(); if (note.id != null) seenIds.add(note.id.href); - const collection = await loadObject(context, contextId, options, budget); - if (!isCollection(collection)) return; - let yielded = 0; try { - for await ( - const object of getCollectionItems(context, collection, options, budget) - ) { + for (const strategy of strategies) { for await ( - const item of getBackfillItems( + const item of getStrategyItems( context, - object, - strategies, + note, + strategy, options, budget, ) @@ -89,8 +82,8 @@ export async function* backfill< object: item.object as TObject, id, strategy: item.strategy, - origin: "collection", - depth: 0, + origin: item.origin, + depth: item.depth, }; yielded++; @@ -106,24 +99,102 @@ export async function* backfill< function normalizeStrategies( strategies: readonly BackfillStrategy[] = defaultStrategies, ): readonly BackfillStrategy[] { - if (strategies.includes("context-auto")) return ["context-auto"]; - return Array.from(new Set(strategies)); + const normalized: BackfillStrategy[] = []; + for (const strategy of strategies) { + if (strategy === "context-auto") { + for (let i = normalized.length - 1; i >= 0; i--) { + if (isContextStrategy(normalized[i])) normalized.splice(i, 1); + } + if (!normalized.includes(strategy)) normalized.push(strategy); + } else if (isContextStrategy(strategy)) { + if ( + !normalized.includes("context-auto") && !normalized.includes( + strategy, + ) + ) { + normalized.push(strategy); + } + } else if (!normalized.includes(strategy)) { + normalized.push(strategy); + } + } + return normalized; } -async function* getBackfillItems( +function isContextStrategy( + strategy: BackfillStrategy, +): strategy is Exclude { + return strategy === "context-objects" || + strategy === "context-activities" || + strategy === "context-auto"; +} + +async function* getStrategyItems( context: BackfillContext, - object: APObject | Link, - strategies: readonly BackfillStrategy[], + note: APObject, + strategy: BackfillStrategy, options: BackfillOptions, budget: RequestBudget, ): AsyncIterable<{ readonly object: APObject; readonly strategy: BackfillStrategy; + readonly origin: BackfillOrigin; + readonly depth: number; }> { - for (const strategy of strategies) { - if (strategy === "context-objects" && isContextPostObject(object)) { - yield { object, strategy }; - } else if (strategy === "context-activities") { + if (isContextStrategy(strategy)) { + const contextId = note.contextIds[0]; + if (contextId == null) return; + const collection = await loadObject(context, contextId, options, budget); + if (!isCollection(collection)) return; + for await ( + const object of getCollectionItems(context, collection, options, budget) + ) { + for await ( + const item of getContextBackfillItems( + context, + object, + strategy, + options, + budget, + ) + ) { + yield { + object: item.object, + strategy: item.strategy, + origin: "collection", + depth: 0, + }; + } + } + } else if (strategy === "reply-tree") { + return; + } +} + +async function* getContextBackfillItems( + context: BackfillContext, + object: APObject | Link, + strategy: Exclude, + options: BackfillOptions, + budget: RequestBudget, +): AsyncIterable<{ + readonly object: APObject; + readonly strategy: Exclude; +}> { + if (strategy === "context-objects" && isContextPostObject(object)) { + yield { object, strategy }; + } else if (strategy === "context-activities") { + const activityObject = await getCreateActivityObject( + context, + object, + options, + budget, + ); + if (activityObject != null && isContextPostObject(activityObject)) { + yield { object: activityObject, strategy }; + } + } else if (strategy === "context-auto") { + if (object instanceof Activity) { const activityObject = await getCreateActivityObject( context, object, @@ -133,20 +204,8 @@ async function* getBackfillItems( if (activityObject != null && isContextPostObject(activityObject)) { yield { object: activityObject, strategy }; } - } else if (strategy === "context-auto") { - if (object instanceof Activity) { - const activityObject = await getCreateActivityObject( - context, - object, - options, - budget, - ); - if (activityObject != null && isContextPostObject(activityObject)) { - yield { object: activityObject, strategy }; - } - } else if (isContextPostObject(object)) { - yield { object, strategy }; - } + } else if (isContextPostObject(object)) { + yield { object, strategy }; } } } From 0c753828f8157452075a481c1ff59e999200a90b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 15 Jun 2026 14:47:00 +0900 Subject: [PATCH 03/17] Walk reply ancestors in backfill Implement the reply-tree ancestor path through inReplyTo targets. Ancestors share the existing document loader, request budget, abort signal, and global output deduplication while keeping a separate visited set to prevent traversal cycles. Add coverage for embedded and dereferenced ancestors, maxDepth, maxRequests, cycle prevention, and deduplication against context collection results. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/backfill.test.ts | 170 +++++++++++++++++++++++++ packages/backfill/src/backfill.ts | 96 +++++++++++++- 2 files changed, 265 insertions(+), 1 deletion(-) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index 66057a06b..a74adbb6d 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -218,6 +218,176 @@ describe("backfill", () => { ); }); + test("reply tree yields embedded ancestor", async () => { + const parent = new Note({ + id: new URL("https://example.com/notes/1"), + content: "parent", + }); + const note = new Note({ + id: new URL("https://example.com/notes/2"), + replyTarget: parent, + }); + const context: BackfillContext = { + documentLoader: () => { + throw new Error("documentLoader should not be called"); + }, + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object, parent); + deepStrictEqual(items[0].id, parent.id); + strictEqual(items[0].strategy, "reply-tree"); + strictEqual(items[0].origin, "in-reply-to"); + strictEqual(items[0].depth, 1); + }); + + test("reply tree dereferences ancestor URL", async () => { + const parentId = new URL("https://example.com/notes/1"); + const parent = new Note({ + id: parentId, + content: "parent", + }); + const note = new Note({ + id: new URL("https://example.com/notes/2"), + replyTarget: parentId, + }); + const context: BackfillContext = { + documentLoader: (iri) => + Promise.resolve(iri.href === parentId.href ? parent : null), + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + }); + + strictEqual(items.length, 1); + deepStrictEqual(items[0].object.id, parent.id); + strictEqual(items[0].origin, "in-reply-to"); + strictEqual(items[0].depth, 1); + }); + + test("reply tree maxDepth limits ancestors", async () => { + const rootId = new URL("https://example.com/notes/1"); + const parentId = new URL("https://example.com/notes/2"); + const root = new Note({ + id: rootId, + content: "root", + }); + const parent = new Note({ + id: parentId, + content: "parent", + replyTarget: rootId, + }); + const note = new Note({ + id: new URL("https://example.com/notes/3"), + replyTarget: parentId, + }); + const context: BackfillContext = { + documentLoader: (iri) => { + if (iri.href === parentId.href) return Promise.resolve(parent); + if (iri.href === rootId.href) return Promise.resolve(root); + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + maxDepth: 1, + }); + + strictEqual(items.length, 1); + deepStrictEqual(items[0].object.id, parent.id); + strictEqual(items[0].depth, 1); + }); + + test("maxRequests limits reply tree ancestor dereferencing", async () => { + const parentId = new URL("https://example.com/notes/1"); + const note = new Note({ + id: new URL("https://example.com/notes/2"), + replyTarget: parentId, + }); + const context: BackfillContext = { + documentLoader: () => { + throw new Error("documentLoader should not be called"); + }, + }; + + deepStrictEqual( + await collect(context, note, { + strategies: ["reply-tree"], + maxRequests: 0, + }), + [], + ); + }); + + test("reply tree avoids ancestor cycles", async () => { + const seedId = new URL("https://example.com/notes/1"); + const parentId = new URL("https://example.com/notes/2"); + const note = new Note({ + id: seedId, + replyTarget: parentId, + }); + const parent = new Note({ + id: parentId, + replyTarget: seedId, + }); + const context: BackfillContext = { + documentLoader: (iri) => { + if (iri.href === seedId.href) return Promise.resolve(note); + if (iri.href === parentId.href) return Promise.resolve(parent); + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + }); + + strictEqual(items.length, 1); + deepStrictEqual(items[0].object.id, parent.id); + }); + + test("reply tree deduplicates ancestors from context collection", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const parentId = new URL("https://example.com/notes/1"); + const parent = new Note({ + id: parentId, + content: "parent", + }); + const note = new Note({ + id: new URL("https://example.com/notes/2"), + contexts: [contextId], + replyTarget: parentId, + }); + const context: BackfillContext = { + documentLoader: (iri) => { + if (iri.href === contextId.href) { + return Promise.resolve( + new Collection({ + id: contextId, + items: [parent], + }), + ); + } + if (iri.href === parentId.href) return Promise.resolve(parent); + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note, { + strategies: ["context-auto", "reply-tree"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object, parent); + strictEqual(items[0].strategy, "context-auto"); + }); + test("context auto overrides overlapping context strategies", async () => { const contextId = new URL("https://example.com/contexts/1"); const item = new Note({ content: "anonymous" }); diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index 308ac554f..36981c8c8 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -167,10 +167,104 @@ async function* getStrategyItems( } } } else if (strategy === "reply-tree") { - return; + yield* getReplyTreeItems(context, note, options, budget); } } +async function* getReplyTreeItems( + context: BackfillContext, + note: APObject, + options: BackfillOptions, + budget: RequestBudget, +): AsyncIterable<{ + readonly object: APObject; + readonly strategy: "reply-tree"; + readonly origin: "in-reply-to"; + readonly depth: number; +}> { + const visitedIds = new Set(); + const visitedObjects = new WeakSet(); + if (note.id != null) visitedIds.add(note.id.href); + visitedObjects.add(note); + yield* getReplyAncestors(context, note, options, budget, { + depth: 1, + visitedIds, + visitedObjects, + }); +} + +async function* getReplyAncestors( + context: BackfillContext, + object: APObject, + options: BackfillOptions, + budget: RequestBudget, + traversal: { + readonly depth: number; + readonly visitedIds: Set; + readonly visitedObjects: WeakSet; + }, +): AsyncIterable<{ + readonly object: APObject; + readonly strategy: "reply-tree"; + readonly origin: "in-reply-to"; + readonly depth: number; +}> { + if (options.maxDepth != null && traversal.depth > options.maxDepth) return; + for await ( + const target of getReplyTargets(context, object, options, budget) + ) { + if (!isContextPostObject(target)) continue; + if (!visitReplyTreeObject(target, traversal)) continue; + yield { + object: target, + strategy: "reply-tree", + origin: "in-reply-to", + depth: traversal.depth, + }; + yield* getReplyAncestors(context, target, options, budget, { + depth: traversal.depth + 1, + visitedIds: traversal.visitedIds, + visitedObjects: traversal.visitedObjects, + }); + } +} + +async function* getReplyTargets( + context: BackfillContext, + object: APObject, + options: BackfillOptions, + budget: RequestBudget, +): AsyncIterable { + try { + yield* object.getReplyTargets({ + documentLoader: async (url) => { + return await loadCollectionItemDocument(context, url, options, budget); + }, + crossOrigin: "trust", + }); + } catch (error) { + if (error instanceof MaxRequestsExceeded) throw error; + budget.signal?.throwIfAborted(); + } +} + +function visitReplyTreeObject( + object: APObject, + traversal: { + readonly visitedIds: Set; + readonly visitedObjects: WeakSet; + }, +): boolean { + if (object.id != null) { + if (traversal.visitedIds.has(object.id.href)) return false; + traversal.visitedIds.add(object.id.href); + } else { + if (traversal.visitedObjects.has(object)) return false; + } + traversal.visitedObjects.add(object); + return true; +} + async function* getContextBackfillItems( context: BackfillContext, object: APObject | Link, From bb4807d11472b997846e27df88cf2ed57d9fe2a3 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 15 Jun 2026 14:56:57 +0900 Subject: [PATCH 04/17] Walk reply descendants in backfill Extend reply-tree traversal to follow replies collections after ancestor lookup. Descendants reuse the existing collection loader, request budget, abort signal, and visited-state handling while reporting replies origin and reply-tree depth metadata. Add coverage for embedded and dereferenced replies collections, maxDepth, maxRequests, and descendant cycle prevention. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/backfill.test.ts | 152 +++++++++++++++++++++++++ packages/backfill/src/backfill.ts | 65 ++++++++++- 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index a74adbb6d..74fb5a7f0 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -388,6 +388,158 @@ describe("backfill", () => { strictEqual(items[0].strategy, "context-auto"); }); + test("reply tree yields embedded descendants", async () => { + const reply = new Note({ + id: new URL("https://example.com/notes/2"), + content: "reply", + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + replies: new Collection({ + id: new URL("https://example.com/notes/1/replies"), + items: [reply], + }), + }); + const context: BackfillContext = { + documentLoader: () => { + throw new Error("documentLoader should not be called"); + }, + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object, reply); + deepStrictEqual(items[0].id, reply.id); + strictEqual(items[0].strategy, "reply-tree"); + strictEqual(items[0].origin, "replies"); + strictEqual(items[0].depth, 1); + }); + + test("reply tree dereferences replies collection URL", async () => { + const repliesId = new URL("https://example.com/notes/1/replies"); + const reply = new Note({ + id: new URL("https://example.com/notes/2"), + content: "reply", + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + replies: repliesId, + }); + const context: BackfillContext = { + documentLoader: (iri) => + Promise.resolve( + iri.href === repliesId.href + ? new Collection({ + id: repliesId, + items: [reply], + }) + : null, + ), + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + }); + + strictEqual(items.length, 1); + deepStrictEqual(items[0].object.id, reply.id); + strictEqual(items[0].origin, "replies"); + strictEqual(items[0].depth, 1); + }); + + test("reply tree maxDepth limits descendants", async () => { + const grandchild = new Note({ + id: new URL("https://example.com/notes/3"), + content: "grandchild", + }); + const reply = new Note({ + id: new URL("https://example.com/notes/2"), + content: "reply", + replies: new Collection({ + id: new URL("https://example.com/notes/2/replies"), + items: [grandchild], + }), + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + replies: new Collection({ + id: new URL("https://example.com/notes/1/replies"), + items: [reply], + }), + }); + const context: BackfillContext = { + documentLoader: () => { + throw new Error("documentLoader should not be called"); + }, + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + maxDepth: 1, + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object, reply); + strictEqual(items[0].depth, 1); + }); + + test("maxRequests limits reply tree replies dereferencing", async () => { + const repliesId = new URL("https://example.com/notes/1/replies"); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + replies: repliesId, + }); + const context: BackfillContext = { + documentLoader: () => { + throw new Error("documentLoader should not be called"); + }, + }; + + deepStrictEqual( + await collect(context, note, { + strategies: ["reply-tree"], + maxRequests: 0, + }), + [], + ); + }); + + test("reply tree avoids descendant cycles", async () => { + const seedId = new URL("https://example.com/notes/1"); + const replyId = new URL("https://example.com/notes/2"); + const note = new Note({ + id: seedId, + }); + const reply = new Note({ + id: replyId, + replies: new Collection({ + id: new URL("https://example.com/notes/2/replies"), + items: [note], + }), + }); + const seed = note.clone({ + replies: new Collection({ + id: new URL("https://example.com/notes/1/replies"), + items: [reply], + }), + }); + const context: BackfillContext = { + documentLoader: () => { + throw new Error("documentLoader should not be called"); + }, + }; + + const items = await collect(context, seed, { + strategies: ["reply-tree"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object, reply); + }); + test("context auto overrides overlapping context strategies", async () => { const contextId = new URL("https://example.com/contexts/1"); const item = new Note({ content: "anonymous" }); diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index 36981c8c8..118b53e14 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -179,7 +179,7 @@ async function* getReplyTreeItems( ): AsyncIterable<{ readonly object: APObject; readonly strategy: "reply-tree"; - readonly origin: "in-reply-to"; + readonly origin: "in-reply-to" | "replies"; readonly depth: number; }> { const visitedIds = new Set(); @@ -191,6 +191,11 @@ async function* getReplyTreeItems( visitedIds, visitedObjects, }); + yield* getReplyDescendants(context, note, options, budget, { + depth: 1, + visitedIds, + visitedObjects, + }); } async function* getReplyAncestors( @@ -229,6 +234,44 @@ async function* getReplyAncestors( } } +async function* getReplyDescendants( + context: BackfillContext, + object: APObject, + options: BackfillOptions, + budget: RequestBudget, + traversal: { + readonly depth: number; + readonly visitedIds: Set; + readonly visitedObjects: WeakSet; + }, +): AsyncIterable<{ + readonly object: APObject; + readonly strategy: "reply-tree"; + readonly origin: "replies"; + readonly depth: number; +}> { + if (options.maxDepth != null && traversal.depth > options.maxDepth) return; + const replies = await getRepliesCollection(context, object, options, budget); + if (replies == null) return; + for await ( + const reply of getCollectionItems(context, replies, options, budget) + ) { + if (!isContextPostObject(reply)) continue; + if (!visitReplyTreeObject(reply, traversal)) continue; + yield { + object: reply, + strategy: "reply-tree", + origin: "replies", + depth: traversal.depth, + }; + yield* getReplyDescendants(context, reply, options, budget, { + depth: traversal.depth + 1, + visitedIds: traversal.visitedIds, + visitedObjects: traversal.visitedObjects, + }); + } +} + async function* getReplyTargets( context: BackfillContext, object: APObject, @@ -248,6 +291,26 @@ async function* getReplyTargets( } } +async function getRepliesCollection( + context: BackfillContext, + object: APObject, + options: BackfillOptions, + budget: RequestBudget, +): Promise { + try { + return await object.getReplies({ + documentLoader: async (url) => { + return await loadCollectionItemDocument(context, url, options, budget); + }, + crossOrigin: "trust", + }); + } catch (error) { + if (error instanceof MaxRequestsExceeded) throw error; + budget.signal?.throwIfAborted(); + return null; + } +} + function visitReplyTreeObject( object: APObject, traversal: { From 44e31748c70a1d55bb9d5a89d9d9f34e60444d31 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 15 Jun 2026 16:25:16 +0900 Subject: [PATCH 05/17] Clarify reply-tree API docs Update the backfill public API comments now that reply-tree traversal is implemented. Document how documentLoader, maxRequests, maxDepth, and item depth apply to reply targets and replies collections. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/types.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts index 72612ad15..a11eeb815 100644 --- a/packages/backfill/src/types.ts +++ b/packages/backfill/src/types.ts @@ -61,7 +61,8 @@ export type BackfillDocumentLoader = ( */ export interface BackfillContext { /** - * Dereferences context collections and collection item IRIs. + * Dereferences context collections, collection item IRIs, reply targets, + * and replies collections. */ readonly documentLoader: BackfillDocumentLoader; } @@ -91,15 +92,20 @@ export interface BackfillOptions< readonly maxItems?: number; /** - * Maximum traversal depth. This is reserved for future reply-tree traversal; + * Maximum reply-tree traversal depth. + * + * Immediate `inReplyTo` targets and direct `replies` collection items have + * depth 1. Their parents or replies have depth 2, and so on. Context + * collection items are depth 0 and are not limited by this option. */ readonly maxDepth?: number; /** * Maximum number of calls to {@link BackfillContext.documentLoader}. * - * Dereferencing the note context, collection item IRIs, and future page IRIs - * all count as requests. Embedded collection items do not count. + * Dereferencing the note context, collection item IRIs, reply target IRIs, + * replies collection IRIs, and future page IRIs all count as requests. + * Embedded objects and collections do not count. */ readonly maxRequests?: number; @@ -148,8 +154,11 @@ export interface BackfillItem< readonly origin: BackfillOrigin; /** - * Traversal depth. Direct context collection items are depth 0; deeper - * values are reserved for future reply-tree traversal. + * Traversal depth. + * + * Direct context collection items are depth 0. Reply-tree items use depth + * 1 for immediate `inReplyTo` targets and direct `replies` collection items, + * depth 2 for the next level, and so on. */ readonly depth?: number; } From 29ab8cdb9ce2b981a4f197fa7a509dfbb196258f Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 15 Jun 2026 16:25:41 +0900 Subject: [PATCH 06/17] Batch adjacent context strategies Preserve the PR 2 context collection behavior by loading the context collection once for adjacent context strategies. This keeps ordered strategy execution while avoiding duplicate context dereferences and request-budget regressions for explicit context strategy combinations. Add a regression test that combined context object and activity strategies share the same context collection load. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/backfill.test.ts | 42 +++++++++++++ packages/backfill/src/backfill.ts | 81 ++++++++++++++++++++------ 2 files changed, 105 insertions(+), 18 deletions(-) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index 74fb5a7f0..b977149d3 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -666,6 +666,48 @@ describe("backfill", () => { strictEqual(items[1].strategy, "context-activities"); }); + test("combined context strategies share context collection loading", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const post = new Note({ + id: new URL("https://example.com/notes/2"), + content: "hello", + }); + const activityObject = new Note({ + id: new URL("https://example.com/notes/3"), + content: "activity object", + }); + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + object: activityObject, + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + let requests = 0; + const context: BackfillContext = { + documentLoader: (iri) => { + requests++; + strictEqual(iri.href, contextId.href); + return Promise.resolve( + new Collection({ + id: contextId, + items: [post, activity], + }), + ); + }, + }; + + const items = await collect(context, note, { + strategies: ["context-objects", "context-activities"], + }); + + strictEqual(requests, 1); + strictEqual(items.length, 2); + strictEqual(items[0].object, post); + strictEqual(items[1].object, activityObject); + }); + test("context activity collection dereferences activity object URL", async () => { const contextId = new URL("https://example.com/contexts/1"); const itemId = new URL("https://example.com/notes/2"); diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index 118b53e14..f03190277 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -33,6 +33,13 @@ interface RequestBudget { requestCount: number; } +type StrategyItem = { + readonly object: APObject; + readonly strategy: BackfillStrategy; + readonly origin: BackfillOrigin; + readonly depth: number; +}; + /** * Backfills post-like objects related to a seed object. * @@ -61,16 +68,37 @@ export async function* backfill< let yielded = 0; try { - for (const strategy of strategies) { - for await ( - const item of getStrategyItems( + for (let i = 0; i < strategies.length; i++) { + const strategy = strategies[i]; + let items: AsyncIterable; + if (isContextStrategy(strategy)) { + const contextStrategies: Exclude[] = [ + strategy, + ]; + while (true) { + const nextStrategy = strategies[i + 1]; + if (nextStrategy == null || !isContextStrategy(nextStrategy)) break; + contextStrategies.push(nextStrategy); + i++; + } + items = getContextStrategyItems( + context, + note, + contextStrategies, + options, + budget, + ); + } else { + items = getStrategyItems( context, note, strategy, options, budget, - ) - ) { + ); + } + + for await (const item of items) { const id = item.object.id ?? undefined; if (id != null) { if (seenIds.has(id.href)) continue; @@ -129,26 +157,26 @@ function isContextStrategy( strategy === "context-auto"; } -async function* getStrategyItems( +async function* getContextStrategyItems( context: BackfillContext, note: APObject, - strategy: BackfillStrategy, + strategies: readonly Exclude[], options: BackfillOptions, budget: RequestBudget, ): AsyncIterable<{ readonly object: APObject; - readonly strategy: BackfillStrategy; - readonly origin: BackfillOrigin; - readonly depth: number; + readonly strategy: Exclude; + readonly origin: "collection"; + readonly depth: 0; }> { - if (isContextStrategy(strategy)) { - const contextId = note.contextIds[0]; - if (contextId == null) return; - const collection = await loadObject(context, contextId, options, budget); - if (!isCollection(collection)) return; - for await ( - const object of getCollectionItems(context, collection, options, budget) - ) { + const contextId = note.contextIds[0]; + if (contextId == null) return; + const collection = await loadObject(context, contextId, options, budget); + if (!isCollection(collection)) return; + for await ( + const object of getCollectionItems(context, collection, options, budget) + ) { + for (const strategy of strategies) { for await ( const item of getContextBackfillItems( context, @@ -166,6 +194,23 @@ async function* getStrategyItems( }; } } + } +} + +async function* getStrategyItems( + context: BackfillContext, + note: APObject, + strategy: BackfillStrategy, + options: BackfillOptions, + budget: RequestBudget, +): AsyncIterable<{ + readonly object: APObject; + readonly strategy: BackfillStrategy; + readonly origin: BackfillOrigin; + readonly depth: number; +}> { + if (isContextStrategy(strategy)) { + yield* getContextStrategyItems(context, note, [strategy], options, budget); } else if (strategy === "reply-tree") { yield* getReplyTreeItems(context, note, options, budget); } From cda9f6cd7a4dcc523371e16ddd1896b634cc1e43 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 15 Jun 2026 16:50:01 +0900 Subject: [PATCH 07/17] Document reply-tree backfill behavior Describe how reply-tree composes with context collection backfill and clarify that reply-tree yields discovered post-like objects without unwrapping Activity objects. Assisted-by: Codex:gpt-5.5 --- packages/backfill/README.md | 18 ++++++++++++++++++ packages/backfill/src/types.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/backfill/README.md b/packages/backfill/README.md index 70576fd39..5162a558c 100644 --- a/packages/backfill/README.md +++ b/packages/backfill/README.md @@ -82,3 +82,21 @@ for await ( The `context-activities` strategy currently supports `Create` activities and yields the activity's object, not the activity itself. + +To combine the FEP-f228 context collection path with traditional reply-tree +crawling, add the `reply-tree` strategy after `context-auto`: + +~~~~ typescript +for await ( + const item of backfill({ documentLoader }, note, { + strategies: ["context-auto", "reply-tree"], + maxDepth: 4, + }) +) { + console.log(item.origin, item.depth, item.object); +} +~~~~ + +The `reply-tree` strategy walks `inReplyTo` ancestors and `replies` +descendants. It yields discovered post-like objects only; it does not extract +objects from Activity wrappers. diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts index a11eeb815..9ef78898a 100644 --- a/packages/backfill/src/types.ts +++ b/packages/backfill/src/types.ts @@ -11,7 +11,7 @@ import type { Object as APObject } from "@fedify/vocab"; * handling direct post-like objects and supported `Create` activities. * If included, it absorbs other context collection strategies. * - `"reply-tree"` walks the reply graph through `inReplyTo` ancestors and - * `replies` descendants. + * `replies` descendants, yielding discovered post-like objects. * * @since 2.x.0 */ From 77a1fe9857db85cf64133d8a4b5d5de14de04e6d Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 15 Jun 2026 19:33:29 +0900 Subject: [PATCH 08/17] Track reply collection visits Separate reply-tree object and collection visited state so traversal can avoid reloading or revisiting replies collections. Mark collection IRIs before loading them and keep embedded collection references in visited state to avoid reply-tree collection cycles. Add coverage for repeated replies collection IRIs sharing a single loader request. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/backfill.test.ts | 34 +++++++++++ packages/backfill/src/backfill.ts | 84 +++++++++++++++++++------- 2 files changed, 96 insertions(+), 22 deletions(-) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index b977149d3..301587991 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -507,6 +507,40 @@ describe("backfill", () => { ); }); + test("reply tree does not reload visited replies collection URL", async () => { + const repliesId = new URL("https://example.com/notes/1/replies"); + const reply = new Note({ + id: new URL("https://example.com/notes/2"), + content: "reply", + replies: repliesId, + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + replies: repliesId, + }); + let requests = 0; + const context: BackfillContext = { + documentLoader: (iri) => { + requests++; + strictEqual(iri.href, repliesId.href); + return Promise.resolve( + new Collection({ + id: repliesId, + items: [reply], + }), + ); + }, + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + }); + + strictEqual(requests, 1); + strictEqual(items.length, 1); + strictEqual(items[0].object.id?.href, reply.id?.href); + }); + test("reply tree avoids descendant cycles", async () => { const seedId = new URL("https://example.com/notes/1"); const replyId = new URL("https://example.com/notes/2"); diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index f03190277..739137def 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -40,6 +40,14 @@ type StrategyItem = { readonly depth: number; }; +type ReplyTreeTraversal = { + readonly depth: number; + readonly visitedObjectIds: Set; + readonly visitedObjects: WeakSet; + readonly visitedCollectionIds: Set; + readonly visitedCollections: WeakSet; +}; + /** * Backfills post-like objects related to a seed object. * @@ -227,19 +235,25 @@ async function* getReplyTreeItems( readonly origin: "in-reply-to" | "replies"; readonly depth: number; }> { - const visitedIds = new Set(); + const visitedObjectIds = new Set(); const visitedObjects = new WeakSet(); - if (note.id != null) visitedIds.add(note.id.href); + const visitedCollectionIds = new Set(); + const visitedCollections = new WeakSet(); + if (note.id != null) visitedObjectIds.add(note.id.href); visitedObjects.add(note); yield* getReplyAncestors(context, note, options, budget, { depth: 1, - visitedIds, + visitedObjectIds, visitedObjects, + visitedCollectionIds, + visitedCollections, }); yield* getReplyDescendants(context, note, options, budget, { depth: 1, - visitedIds, + visitedObjectIds, visitedObjects, + visitedCollectionIds, + visitedCollections, }); } @@ -248,11 +262,7 @@ async function* getReplyAncestors( object: APObject, options: BackfillOptions, budget: RequestBudget, - traversal: { - readonly depth: number; - readonly visitedIds: Set; - readonly visitedObjects: WeakSet; - }, + traversal: ReplyTreeTraversal, ): AsyncIterable<{ readonly object: APObject; readonly strategy: "reply-tree"; @@ -273,8 +283,10 @@ async function* getReplyAncestors( }; yield* getReplyAncestors(context, target, options, budget, { depth: traversal.depth + 1, - visitedIds: traversal.visitedIds, + visitedObjectIds: traversal.visitedObjectIds, visitedObjects: traversal.visitedObjects, + visitedCollectionIds: traversal.visitedCollectionIds, + visitedCollections: traversal.visitedCollections, }); } } @@ -284,11 +296,7 @@ async function* getReplyDescendants( object: APObject, options: BackfillOptions, budget: RequestBudget, - traversal: { - readonly depth: number; - readonly visitedIds: Set; - readonly visitedObjects: WeakSet; - }, + traversal: ReplyTreeTraversal, ): AsyncIterable<{ readonly object: APObject; readonly strategy: "reply-tree"; @@ -296,8 +304,19 @@ async function* getReplyDescendants( readonly depth: number; }> { if (options.maxDepth != null && traversal.depth > options.maxDepth) return; + const repliesId = object.repliesId; + let repliesIdVisited = false; + if (repliesId != null && !visitReplyTreeCollectionId(repliesId, traversal)) { + return; + } + repliesIdVisited = repliesId != null; const replies = await getRepliesCollection(context, object, options, budget); if (replies == null) return; + if (repliesIdVisited) { + traversal.visitedCollections.add(replies); + } else if (!visitReplyTreeCollection(replies, traversal)) { + return; + } for await ( const reply of getCollectionItems(context, replies, options, budget) ) { @@ -311,8 +330,10 @@ async function* getReplyDescendants( }; yield* getReplyDescendants(context, reply, options, budget, { depth: traversal.depth + 1, - visitedIds: traversal.visitedIds, + visitedObjectIds: traversal.visitedObjectIds, visitedObjects: traversal.visitedObjects, + visitedCollectionIds: traversal.visitedCollectionIds, + visitedCollections: traversal.visitedCollections, }); } } @@ -358,14 +379,11 @@ async function getRepliesCollection( function visitReplyTreeObject( object: APObject, - traversal: { - readonly visitedIds: Set; - readonly visitedObjects: WeakSet; - }, + traversal: ReplyTreeTraversal, ): boolean { if (object.id != null) { - if (traversal.visitedIds.has(object.id.href)) return false; - traversal.visitedIds.add(object.id.href); + if (traversal.visitedObjectIds.has(object.id.href)) return false; + traversal.visitedObjectIds.add(object.id.href); } else { if (traversal.visitedObjects.has(object)) return false; } @@ -373,6 +391,28 @@ function visitReplyTreeObject( return true; } +function visitReplyTreeCollection( + collection: BackfillCollection, + traversal: ReplyTreeTraversal, +): boolean { + if (collection.id != null) { + return visitReplyTreeCollectionId(collection.id, traversal); + } else { + if (traversal.visitedCollections.has(collection)) return false; + } + traversal.visitedCollections.add(collection); + return true; +} + +function visitReplyTreeCollectionId( + id: URL, + traversal: ReplyTreeTraversal, +): boolean { + if (traversal.visitedCollectionIds.has(id.href)) return false; + traversal.visitedCollectionIds.add(id.href); + return true; +} + async function* getContextBackfillItems( context: BackfillContext, object: APObject | Link, From 1e5d4226fefadc9a7435048294e2344f7c721c44 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 15 Jun 2026 19:44:07 +0900 Subject: [PATCH 09/17] Cover shared backfill budgets Add tests that exercise shared maxItems, maxRequests, and abort behavior across context collection and reply-tree strategies. This protects the ordered hybrid execution path where context-auto runs before reply-tree. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/backfill.test.ts | 123 +++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index 301587991..6d49d2a18 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -1142,6 +1142,44 @@ describe("backfill", () => { strictEqual(items[0].id?.href, "https://example.com/notes/2"); }); + test("maxItems is shared across context and reply tree", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const reply = new Note({ + id: new URL("https://example.com/notes/3"), + content: "reply", + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + replies: new Collection({ + id: new URL("https://example.com/notes/1/replies"), + items: [reply], + }), + }); + const contextItem = new Note({ + id: new URL("https://example.com/notes/2"), + content: "context item", + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [contextItem], + }), + ), + }; + + const items = await collect(context, note, { + strategies: ["context-auto", "reply-tree"], + maxItems: 1, + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object, contextItem); + strictEqual(items[0].strategy, "context-auto"); + }); + test("maxRequests limits dereferencing", async () => { const contextId = new URL("https://example.com/contexts/1"); const itemId = new URL("https://example.com/notes/2"); @@ -1166,6 +1204,41 @@ describe("backfill", () => { deepStrictEqual(await collect(context, note, { maxRequests: 1 }), []); }); + test("maxRequests is shared across context and reply tree", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const parentId = new URL("https://example.com/notes/0"); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + replyTarget: parentId, + }); + const contextItem = new Note({ + id: new URL("https://example.com/notes/2"), + content: "context item", + }); + const context: BackfillContext = { + documentLoader: (iri) => { + if (iri.href === contextId.href) { + return Promise.resolve( + new Collection({ + id: contextId, + items: [contextItem], + }), + ); + } + throw new Error("reply-tree request should be budgeted out"); + }, + }; + + const items = await collect(context, note, { + strategies: ["context-auto", "reply-tree"], + maxRequests: 1, + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object, contextItem); + }); + test("AbortSignal stops traversal", async () => { const contextId = new URL("https://example.com/contexts/1"); const note = new Note({ @@ -1190,6 +1263,56 @@ describe("backfill", () => { ); }); + test("AbortSignal stops traversal across strategies", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const parentId = new URL("https://example.com/notes/0"); + const controller = new AbortController(); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + replyTarget: parentId, + }); + const contextItem = new Note({ + id: new URL("https://example.com/notes/2"), + content: "context item", + }); + let requests = 0; + const context: BackfillContext = { + documentLoader: (iri) => { + requests++; + if (iri.href === contextId.href) { + return Promise.resolve( + new Collection({ + id: contextId, + items: [contextItem], + }), + ); + } + throw new Error("reply-tree request should not be started"); + }, + }; + + const items: Awaited> = []; + await rejects( + async () => { + for await ( + const item of backfill(context, note, { + strategies: ["context-auto", "reply-tree"], + signal: controller.signal, + }) + ) { + items.push(item); + controller.abort(); + } + }, + { name: "AbortError" }, + ); + + strictEqual(requests, 1); + strictEqual(items.length, 1); + strictEqual(items[0].object, contextItem); + }); + test("documentLoader receives AbortSignal", async () => { const contextId = new URL("https://example.com/contexts/1"); const note = new Note({ From 8f70443d924ebe4975a781889251885d987cffaf Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 15 Jun 2026 19:49:20 +0900 Subject: [PATCH 10/17] Cover ordered backfill strategy dedupe Add a regression test for hybrid backfill ordering where reply-tree runs before context-auto. The test protects the contract that earlier strategies keep their BackfillItem metadata when later strategies discover the same object. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/backfill.test.ts | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index 6d49d2a18..80263d454 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -388,6 +388,43 @@ describe("backfill", () => { strictEqual(items[0].strategy, "context-auto"); }); + test("strategy order controls deduplicated item metadata", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const parentId = new URL("https://example.com/notes/1"); + const parent = new Note({ + id: parentId, + content: "parent", + }); + const note = new Note({ + id: new URL("https://example.com/notes/2"), + contexts: [contextId], + replyTarget: parentId, + }); + const context: BackfillContext = { + documentLoader: (iri) => { + if (iri.href === parentId.href) return Promise.resolve(parent); + if (iri.href === contextId.href) { + return Promise.resolve( + new Collection({ + id: contextId, + items: [parent], + }), + ); + } + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note, { + strategies: ["reply-tree", "context-auto"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object.id?.href, parentId.href); + strictEqual(items[0].strategy, "reply-tree"); + strictEqual(items[0].origin, "in-reply-to"); + }); + test("reply tree yields embedded descendants", async () => { const reply = new Note({ id: new URL("https://example.com/notes/2"), From badd88dc882633750396eea9ab439757efd73c3b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 15 Jun 2026 19:51:46 +0900 Subject: [PATCH 11/17] Document ordered backfill strategies Clarify that backfill strategies run in order while sharing item, request, abort, and deduplication state. Also update the README to describe the opt-in reply-tree strategy alongside the FEP-f228 context collection path. Assisted-by: Codex:gpt-5.5 --- packages/backfill/README.md | 11 +++++++++-- packages/backfill/src/types.ts | 10 +++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/backfill/README.md b/packages/backfill/README.md index 5162a558c..15bac9189 100644 --- a/packages/backfill/README.md +++ b/packages/backfill/README.md @@ -13,7 +13,9 @@ This package provides ActivityPub conversation backfill support for the [Fedify] ecosystem. It can retrieve post-like objects from a seed object's context collection, following the direct FEP-f228-style path where the context dereferences to a `Collection`, `OrderedCollection`, `CollectionPage`, -or `OrderedCollectionPage`. +or `OrderedCollectionPage`. It can also use an opt-in reply-tree strategy to +walk `inReplyTo` ancestors and `replies` descendants when context collections +are unavailable or incomplete. [JSR badge]: https://jsr.io/badges/@fedify/backfill [JSR]: https://jsr.io/@fedify/backfill @@ -62,6 +64,10 @@ for await ( The seed object itself is not yielded. If it appears in the discovered collection, it is skipped by ID. +Configured strategies run in order. They share `maxItems`, `maxRequests`, +abort state, and object ID deduplication; if two strategies discover the same +object, the first strategy keeps its `BackfillItem` metadata. + By default, `backfill()` uses the `context-auto` strategy. In this mode, collection items are treated as backfillable objects by default. If an item is recognized as a supported `Create` activity, `backfill()` extracts the @@ -99,4 +105,5 @@ for await ( The `reply-tree` strategy walks `inReplyTo` ancestors and `replies` descendants. It yields discovered post-like objects only; it does not extract -objects from Activity wrappers. +objects from Activity wrappers. Immediate parents and direct replies have +depth 1, their next-level parents or replies have depth 2, and so on. diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts index 9ef78898a..a78b6ea75 100644 --- a/packages/backfill/src/types.ts +++ b/packages/backfill/src/types.ts @@ -68,7 +68,7 @@ export interface BackfillContext { } /** - * Controls direct context collection backfill traversal. + * Controls backfill traversal. * * @since 2.x.0 */ @@ -78,6 +78,10 @@ export interface BackfillOptions< /** * Backfill strategies to run. * + * Strategies run in order and share request, item, abort, and deduplication + * state. If multiple strategies discover the same object ID, the first + * strategy keeps its {@link BackfillItem} metadata. + * * Defaults to `["context-auto"]`. * If `"context-auto"` is included, it absorbs other context collection * strategies. @@ -104,8 +108,8 @@ export interface BackfillOptions< * Maximum number of calls to {@link BackfillContext.documentLoader}. * * Dereferencing the note context, collection item IRIs, reply target IRIs, - * replies collection IRIs, and future page IRIs all count as requests. - * Embedded objects and collections do not count. + * replies collection IRIs, and future page IRIs all count as requests across + * all strategies. Embedded objects and collections do not count. */ readonly maxRequests?: number; From cc012abd973f3730861a3ff37044d699fc3af874 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 16 Jun 2026 12:54:33 +0900 Subject: [PATCH 12/17] Preserve strategy order around context auto Limit context-auto absorption to the current adjacent context strategy group so reply-tree keeps acting as an ordering boundary. Add a regression test for context-objects before reply-tree followed by context-auto. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/backfill.test.ts | 37 ++++++++++++++++++++++++++ packages/backfill/src/backfill.ts | 24 +++++++++++++---- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index 80263d454..bac8bf319 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -425,6 +425,43 @@ describe("backfill", () => { strictEqual(items[0].origin, "in-reply-to"); }); + test("context auto preserves strategy order across reply tree", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const parentId = new URL("https://example.com/notes/1"); + const parent = new Note({ + id: parentId, + content: "parent", + }); + const note = new Note({ + id: new URL("https://example.com/notes/2"), + contexts: [contextId], + replyTarget: parentId, + }); + const context: BackfillContext = { + documentLoader: (iri) => { + if (iri.href === contextId.href) { + return Promise.resolve( + new Collection({ + id: contextId, + items: [parent], + }), + ); + } + if (iri.href === parentId.href) return Promise.resolve(parent); + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note, { + strategies: ["context-objects", "reply-tree", "context-auto"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object.id?.href, parentId.href); + strictEqual(items[0].strategy, "context-objects"); + strictEqual(items[0].origin, "collection"); + }); + test("reply tree yields embedded descendants", async () => { const reply = new Note({ id: new URL("https://example.com/notes/2"), diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index 739137def..94a10b562 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -138,15 +138,18 @@ function normalizeStrategies( const normalized: BackfillStrategy[] = []; for (const strategy of strategies) { if (strategy === "context-auto") { - for (let i = normalized.length - 1; i >= 0; i--) { - if (isContextStrategy(normalized[i])) normalized.splice(i, 1); + for ( + let i = normalized.length - 1; + i >= 0 && isContextStrategy(normalized[i]); + i-- + ) { + normalized.splice(i, 1); } if (!normalized.includes(strategy)) normalized.push(strategy); } else if (isContextStrategy(strategy)) { if ( - !normalized.includes("context-auto") && !normalized.includes( - strategy, - ) + !currentContextGroupHasAuto(normalized) && + !normalized.includes(strategy) ) { normalized.push(strategy); } @@ -165,6 +168,17 @@ function isContextStrategy( strategy === "context-auto"; } +function currentContextGroupHasAuto( + strategies: readonly BackfillStrategy[], +): boolean { + for (let i = strategies.length - 1; i >= 0; i--) { + const strategy = strategies[i]; + if (!isContextStrategy(strategy)) return false; + if (strategy === "context-auto") return true; + } + return false; +} + async function* getContextStrategyItems( context: BackfillContext, note: APObject, From e6c75deb722367fb3ec3bdd71275331402b52801 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 16 Jun 2026 15:15:22 +0900 Subject: [PATCH 13/17] Skip seen context item URLs Avoid dereferencing context collection URL items whose object IDs are already known. This prevents fetching the seed object again when a context collection includes it and preserves request budget for unseen items. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/backfill.test.ts | 39 ++++++++++++++++++++++++++ packages/backfill/src/backfill.ts | 35 ++++++++++++++++++++--- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index bac8bf319..817d239f3 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -1103,6 +1103,45 @@ describe("backfill", () => { ]); }); + test("seen context collection URL items are not loaded", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const seedId = new URL("https://example.com/notes/1"); + const itemId = new URL("https://example.com/notes/2"); + const item = new Note({ + id: itemId, + content: "hello", + }); + const note = new Note({ + id: seedId, + contexts: [contextId], + }); + const requests: URL[] = []; + const context: BackfillContext = { + documentLoader: (iri) => { + requests.push(iri); + if (iri.href === contextId.href) { + return Promise.resolve( + new Collection({ + id: contextId, + items: [seedId, itemId], + }), + ); + } + if (iri.href === itemId.href) return Promise.resolve(item); + throw new Error("seen collection item should not be loaded"); + }, + }; + + const items = await collect(context, note); + + strictEqual(items.length, 1); + strictEqual(items[0].id?.href, itemId.href); + deepStrictEqual(requests.map((url) => url.href), [ + contextId.href, + itemId.href, + ]); + }); + test("failed URL collection items are skipped", async () => { const contextId = new URL("https://example.com/contexts/1"); const missingItemId = new URL("https://example.com/notes/missing"); diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index 94a10b562..c5e9c7145 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -95,6 +95,7 @@ export async function* backfill< contextStrategies, options, budget, + seenIds, ); } else { items = getStrategyItems( @@ -103,6 +104,7 @@ export async function* backfill< strategy, options, budget, + seenIds, ); } @@ -185,6 +187,7 @@ async function* getContextStrategyItems( strategies: readonly Exclude[], options: BackfillOptions, budget: RequestBudget, + seenIds: ReadonlySet, ): AsyncIterable<{ readonly object: APObject; readonly strategy: Exclude; @@ -196,7 +199,13 @@ async function* getContextStrategyItems( const collection = await loadObject(context, contextId, options, budget); if (!isCollection(collection)) return; for await ( - const object of getCollectionItems(context, collection, options, budget) + const object of getCollectionItems( + context, + collection, + options, + budget, + seenIds, + ) ) { for (const strategy of strategies) { for await ( @@ -225,6 +234,7 @@ async function* getStrategyItems( strategy: BackfillStrategy, options: BackfillOptions, budget: RequestBudget, + seenIds: ReadonlySet, ): AsyncIterable<{ readonly object: APObject; readonly strategy: BackfillStrategy; @@ -232,7 +242,14 @@ async function* getStrategyItems( readonly depth: number; }> { if (isContextStrategy(strategy)) { - yield* getContextStrategyItems(context, note, [strategy], options, budget); + yield* getContextStrategyItems( + context, + note, + [strategy], + options, + budget, + seenIds, + ); } else if (strategy === "reply-tree") { yield* getReplyTreeItems(context, note, options, budget); } @@ -471,10 +488,17 @@ async function* getCollectionItems( collection: BackfillCollection, options: BackfillOptions, budget: RequestBudget, + skipIds?: ReadonlySet, ): AsyncIterable { yield* collection.getItems({ documentLoader: async (url) => { - return await loadCollectionItemDocument(context, url, options, budget); + return await loadCollectionItemDocument( + context, + url, + options, + budget, + skipIds, + ); }, crossOrigin: "trust", }); @@ -506,12 +530,15 @@ async function loadCollectionItemDocument( url: string, options: BackfillOptions, budget: RequestBudget, + skipIds?: ReadonlySet, ) { let object: APObject | null; try { + const iri = new URL(url); + if (skipIds?.has(iri.href)) return skippedCollectionItemDocument(url); object = await loadObject( context, - new URL(url), + iri, options, budget, true, From e7a2e693e618f46eedb61bd2d0bf5ae99fb5a52c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 16 Jun 2026 15:44:18 +0900 Subject: [PATCH 14/17] Cache dereferenced backfill documents Keep a traversal-local document cache in the shared request budget so overlapping strategies do not dereference the same IRI more than once. Cache hits bypass request budgeting and interval delays while thrown loader failures remain retryable. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/backfill.test.ts | 41 ++++++++++++++++++++++++++ packages/backfill/src/backfill.ts | 17 ++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index 817d239f3..1ad289a03 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -388,6 +388,47 @@ describe("backfill", () => { strictEqual(items[0].strategy, "context-auto"); }); + test("document cache avoids duplicate dereferences across strategies", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const parentId = new URL("https://example.com/notes/1"); + const parent = new Note({ + id: parentId, + content: "parent", + }); + const note = new Note({ + id: new URL("https://example.com/notes/2"), + contexts: [contextId], + replyTarget: parentId, + }); + const requests: URL[] = []; + const context: BackfillContext = { + documentLoader: (iri) => { + requests.push(iri); + if (iri.href === contextId.href) { + return Promise.resolve( + new Collection({ + id: contextId, + items: [parentId], + }), + ); + } + if (iri.href === parentId.href) return Promise.resolve(parent); + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note, { + strategies: ["context-auto", "reply-tree"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object.id?.href, parentId.href); + deepStrictEqual(requests.map((url) => url.href), [ + contextId.href, + parentId.href, + ]); + }); + test("strategy order controls deduplicated item metadata", async () => { const contextId = new URL("https://example.com/contexts/1"); const parentId = new URL("https://example.com/notes/1"); diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index c5e9c7145..104330683 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -31,6 +31,7 @@ export class MaxRequestsExceeded extends Error {} interface RequestBudget { readonly signal?: AbortSignal; requestCount: number; + readonly documents: Map>; } type StrategyItem = { @@ -70,6 +71,7 @@ export async function* backfill< const budget: RequestBudget = { signal: options.signal, requestCount: 0, + documents: new Map(), }; const seenIds = new Set(); if (note.id != null) seenIds.add(note.id.href); @@ -575,6 +577,10 @@ async function loadObject( throwOnBudgetExceeded = false, ): Promise { budget.signal?.throwIfAborted(); + const cacheKey = iri.href; + const cached = budget.documents.get(cacheKey); + if (cached != null) return await cached; + if ( options.maxRequests != null && budget.requestCount >= options.maxRequests @@ -587,7 +593,16 @@ async function loadObject( budget.signal?.throwIfAborted(); budget.requestCount++; - return await context.documentLoader(iri, { signal: budget.signal }); + const document = context.documentLoader(iri, { signal: budget.signal }); + budget.documents.set(cacheKey, document); + try { + return await document; + } catch (error) { + if (budget.documents.get(cacheKey) === document) { + budget.documents.delete(cacheKey); + } + throw error; + } } async function waitForInterval( From 7d117ee9d4236f73b11c8730faa0e3b6249b085c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 16 Jun 2026 16:10:31 +0900 Subject: [PATCH 15/17] Cover backfill cache retry behavior Add a regression test proving failed documentLoader calls are removed from the traversal-local cache. A later strategy can retry the same IRI and yield the recovered object. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/backfill.test.ts | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index 1ad289a03..f4a6fc63d 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -429,6 +429,53 @@ describe("backfill", () => { ]); }); + test("document cache does not keep failed dereferences", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const parentId = new URL("https://example.com/notes/1"); + const parent = new Note({ + id: parentId, + content: "parent", + }); + const note = new Note({ + id: new URL("https://example.com/notes/2"), + contexts: [contextId], + replyTarget: parentId, + }); + const requests: URL[] = []; + let parentRequests = 0; + const context: BackfillContext = { + documentLoader: (iri) => { + requests.push(iri); + if (iri.href === contextId.href) { + return Promise.resolve( + new Collection({ + id: contextId, + items: [parentId], + }), + ); + } + if (iri.href === parentId.href) { + parentRequests++; + if (parentRequests === 1) throw new Error("temporary failure"); + return Promise.resolve(parent); + } + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note, { + strategies: ["context-auto", "reply-tree"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object.id?.href, parentId.href); + deepStrictEqual(requests.map((url) => url.href), [ + contextId.href, + parentId.href, + parentId.href, + ]); + }); + test("strategy order controls deduplicated item metadata", async () => { const contextId = new URL("https://example.com/contexts/1"); const parentId = new URL("https://example.com/notes/1"); From 8f29cbbe4b5206b93423ef58a8a6e245e0a45b73 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 16 Jun 2026 19:13:48 +0900 Subject: [PATCH 16/17] Fix reply-tree sibling traversal Start descendant traversal from discovered ancestors as well as the seed so reply-tree backfill does not miss sibling branches when the seed is itself a reply. Also skip already visited reply IRIs before dereferencing collection items so bounded crawls do not waste request budget on known objects. Add regressions covering both behaviors. Assisted-by: Codex:gpt-5.5 --- packages/backfill/src/backfill.test.ts | 71 ++++++++++++++++++++++++++ packages/backfill/src/backfill.ts | 37 +++++++++++--- 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index f4a6fc63d..fb2bc900c 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -580,6 +580,41 @@ describe("backfill", () => { strictEqual(items[0].depth, 1); }); + test("reply tree walks sibling descendants from discovered ancestor", async () => { + const seedId = new URL("https://example.com/notes/2"); + const sibling = new Note({ + id: new URL("https://example.com/notes/3"), + content: "sibling", + }); + const parent = new Note({ + id: new URL("https://example.com/notes/1"), + content: "parent", + replies: new Collection({ + id: new URL("https://example.com/notes/1/replies"), + items: [seedId, sibling], + }), + }); + const note = new Note({ + id: seedId, + replyTarget: parent, + }); + const context: BackfillContext = { + documentLoader: () => { + throw new Error("documentLoader should not be called"); + }, + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + }); + + strictEqual(items.length, 2); + strictEqual(items[0].object, parent); + strictEqual(items[0].origin, "in-reply-to"); + strictEqual(items[1].object, sibling); + strictEqual(items[1].origin, "replies"); + }); + test("reply tree dereferences replies collection URL", async () => { const repliesId = new URL("https://example.com/notes/1/replies"); const reply = new Note({ @@ -703,6 +738,42 @@ describe("backfill", () => { strictEqual(items[0].object.id?.href, reply.id?.href); }); + test("reply tree skips visited reply IRIs before dereferencing", async () => { + const seedId = new URL("https://example.com/notes/1"); + const siblingId = new URL("https://example.com/notes/2"); + const sibling = new Note({ + id: siblingId, + content: "sibling", + }); + const note = new Note({ + id: seedId, + replies: new Collection({ + id: new URL("https://example.com/notes/1/replies"), + items: [seedId, siblingId], + }), + }); + const requests: string[] = []; + const context: BackfillContext = { + documentLoader: (iri) => { + requests.push(iri.href); + if (iri.href === siblingId.href) return Promise.resolve(sibling); + if (iri.href === seedId.href) { + throw new Error("seed should have been skipped"); + } + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + maxRequests: 1, + }); + + deepStrictEqual(requests, [siblingId.href]); + strictEqual(items.length, 1); + strictEqual(items[0].object.id?.href, siblingId.href); + }); + test("reply tree avoids descendant cycles", async () => { const seedId = new URL("https://example.com/notes/1"); const replyId = new URL("https://example.com/notes/2"); diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index 104330683..bc4c60f8d 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -274,13 +274,28 @@ async function* getReplyTreeItems( const visitedCollections = new WeakSet(); if (note.id != null) visitedObjectIds.add(note.id.href); visitedObjects.add(note); - yield* getReplyAncestors(context, note, options, budget, { - depth: 1, - visitedObjectIds, - visitedObjects, - visitedCollectionIds, - visitedCollections, - }); + const ancestors: APObject[] = []; + for await ( + const item of getReplyAncestors(context, note, options, budget, { + depth: 1, + visitedObjectIds, + visitedObjects, + visitedCollectionIds, + visitedCollections, + }) + ) { + ancestors.push(item.object); + yield item; + } + for (const object of ancestors.toReversed()) { + yield* getReplyDescendants(context, object, options, budget, { + depth: 1, + visitedObjectIds, + visitedObjects, + visitedCollectionIds, + visitedCollections, + }); + } yield* getReplyDescendants(context, note, options, budget, { depth: 1, visitedObjectIds, @@ -351,7 +366,13 @@ async function* getReplyDescendants( return; } for await ( - const reply of getCollectionItems(context, replies, options, budget) + const reply of getCollectionItems( + context, + replies, + options, + budget, + traversal.visitedObjectIds, + ) ) { if (!isContextPostObject(reply)) continue; if (!visitReplyTreeObject(reply, traversal)) continue; From 925f1d54a15b720b8b35023360d30209d3f6c5cc Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 18 Jun 2026 22:11:58 +0900 Subject: [PATCH 17/17] Limit reply-tree depth by default Apply a default maximum depth of 10 to ancestor and descendant reply-tree traversal while preserving explicit maxDepth overrides. Document the default and cover both traversal directions with tests. Assisted-by: Codex:gpt-5.4 --- packages/backfill/README.md | 2 + packages/backfill/src/backfill.test.ts | 51 ++++++++++++++++++++++++++ packages/backfill/src/backfill.ts | 6 ++- packages/backfill/src/types.ts | 2 + 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/backfill/README.md b/packages/backfill/README.md index 15bac9189..cd1e5f33b 100644 --- a/packages/backfill/README.md +++ b/packages/backfill/README.md @@ -107,3 +107,5 @@ The `reply-tree` strategy walks `inReplyTo` ancestors and `replies` descendants. It yields discovered post-like objects only; it does not extract objects from Activity wrappers. Immediate parents and direct replies have depth 1, their next-level parents or replies have depth 2, and so on. +Reply-tree traversal defaults to a maximum depth of 10; set `maxDepth` to use a +different limit. diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index fb2bc900c..9a38c6278 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -304,6 +304,30 @@ describe("backfill", () => { strictEqual(items[0].depth, 1); }); + test("reply tree defaults maxDepth to 10 for ancestors", async () => { + let note = new Note({ + id: new URL("https://example.com/notes/0"), + }); + for (let i = 1; i <= 12; i++) { + note = new Note({ + id: new URL(`https://example.com/notes/${i}`), + replyTarget: note, + }); + } + const context: BackfillContext = { + documentLoader: () => { + throw new Error("documentLoader should not be called"); + }, + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + }); + + strictEqual(items.length, 10); + strictEqual(items.at(-1)?.depth, 10); + }); + test("maxRequests limits reply tree ancestor dereferencing", async () => { const parentId = new URL("https://example.com/notes/1"); const note = new Note({ @@ -683,6 +707,33 @@ describe("backfill", () => { strictEqual(items[0].depth, 1); }); + test("reply tree defaults maxDepth to 10 for descendants", async () => { + let note = new Note({ + id: new URL("https://example.com/notes/12"), + }); + for (let i = 11; i >= 0; i--) { + note = new Note({ + id: new URL(`https://example.com/notes/${i}`), + replies: new Collection({ + id: new URL(`https://example.com/notes/${i}/replies`), + items: [note], + }), + }); + } + const context: BackfillContext = { + documentLoader: () => { + throw new Error("documentLoader should not be called"); + }, + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + }); + + strictEqual(items.length, 10); + strictEqual(items.at(-1)?.depth, 10); + }); + test("maxRequests limits reply tree replies dereferencing", async () => { const repliesId = new URL("https://example.com/notes/1/replies"); const note = new Note({ diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index bc4c60f8d..db56361f2 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -21,6 +21,8 @@ const defaultStrategies = [ "context-auto", ] as const satisfies readonly BackfillStrategy[]; +const DEFAULT_MAX_DEPTH = 10; + /** * Thrown when backfill traversal exceeds the configured request budget. * @@ -317,7 +319,7 @@ async function* getReplyAncestors( readonly origin: "in-reply-to"; readonly depth: number; }> { - if (options.maxDepth != null && traversal.depth > options.maxDepth) return; + if (traversal.depth > (options.maxDepth ?? DEFAULT_MAX_DEPTH)) return; for await ( const target of getReplyTargets(context, object, options, budget) ) { @@ -351,7 +353,7 @@ async function* getReplyDescendants( readonly origin: "replies"; readonly depth: number; }> { - if (options.maxDepth != null && traversal.depth > options.maxDepth) return; + if (traversal.depth > (options.maxDepth ?? DEFAULT_MAX_DEPTH)) return; const repliesId = object.repliesId; let repliesIdVisited = false; if (repliesId != null && !visitReplyTreeCollectionId(repliesId, traversal)) { diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts index a78b6ea75..c7d15f80d 100644 --- a/packages/backfill/src/types.ts +++ b/packages/backfill/src/types.ts @@ -101,6 +101,8 @@ export interface BackfillOptions< * Immediate `inReplyTo` targets and direct `replies` collection items have * depth 1. Their parents or replies have depth 2, and so on. Context * collection items are depth 0 and are not limited by this option. + * + * Defaults to 10. */ readonly maxDepth?: number;