From b34d840d330185811e0f364f4ce4ff9fd0a56916 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sat, 23 May 2026 01:59:55 +0900 Subject: [PATCH 01/37] Add @fedify/backfill --- deno.json | 1 + deno.lock | 13 ------ packages/backfill/README.md | 58 +++++++++++++++++++++++ packages/backfill/deno.json | 22 +++++++++ packages/backfill/package.json | 66 ++++++++++++++++++++++++++ packages/backfill/src/mod.ts | 8 ++++ packages/backfill/tsdown.config.ts | 14 ++++++ packages/fedify/README.md | 3 ++ pnpm-lock.yaml | 75 +++++++++++++++++------------- pnpm-workspace.yaml | 1 + 10 files changed, 215 insertions(+), 46 deletions(-) create mode 100644 packages/backfill/README.md create mode 100644 packages/backfill/deno.json create mode 100644 packages/backfill/package.json create mode 100644 packages/backfill/src/mod.ts create mode 100644 packages/backfill/tsdown.config.ts diff --git a/deno.json b/deno.json index 2740fe03e..92d23ca59 100644 --- a/deno.json +++ b/deno.json @@ -2,6 +2,7 @@ "workspace": [ "./packages/amqp", "./packages/astro", + "./packages/backfill", "./packages/cfworkers", "./packages/cli", "./packages/debugger", diff --git a/deno.lock b/deno.lock index bef73340b..f7cd75f23 100644 --- a/deno.lock +++ b/deno.lock @@ -84,7 +84,6 @@ "npm:@jimp/core@^1.6.1": "1.6.1", "npm:@jimp/wasm-webp@^1.6.1": "1.6.1", "npm:@js-temporal/polyfill@~0.5.1": "0.5.1", - "npm:@jsr/std__assert@0.226": "0.226.0", "npm:@multiformats/base-x@^4.0.1": "4.0.1", "npm:@nestjs/common@^11.0.1": "11.1.19_reflect-metadata@0.2.2_rxjs@7.8.2", "npm:@nurodev/astro-bun@^2.1.2": "2.1.2_astro@5.18.1__@types+node@24.12.2__mysql2@3.22.3___@types+node@24.12.2_@types+node@24.12.2_mysql2@3.22.3__@types+node@24.12.2", @@ -2382,17 +2381,6 @@ "wasm-feature-detect" ] }, - "@jsr/std__assert@0.226.0": { - "integrity": "sha512-xCuUFDfHkIZd96glKgjZbnYFqu6blu8Y53SyvDMlFDJm1Y/j+/FcW6xq7TzGFIaF5B9QecIlDfamfhzA8ZdVbg==", - "dependencies": [ - "@jsr/std__internal" - ], - "tarball": "https://npm.jsr.io/~/11/@jsr/std__assert/0.226.0.tgz" - }, - "@jsr/std__internal@1.0.12": { - "integrity": "sha512-6xReMW9p+paJgqoFRpOE2nogJFvzPfaLHLIlyADYjKMUcwDyjKZxryIbgcU+gxiTygn8yCjld1HoI0ET4/iZeA==", - "tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz" - }, "@logtape/logtape@1.3.7": { "integrity": "sha512-YgF+q9op97oLLPwc7TcTNIllTArVtTwkwyKky6XVzAXQcBrvFXXtMuwJSryONAyOUSItrx994O/HABOrszZyFg==" }, @@ -9472,7 +9460,6 @@ "packageJson": { "dependencies": [ "npm:@js-temporal/polyfill@~0.5.1", - "npm:@jsr/std__assert@0.226", "npm:@types/node@^24.2.1", "npm:json-canon@^1.0.1", "npm:jsonld@9", diff --git a/packages/backfill/README.md b/packages/backfill/README.md new file mode 100644 index 000000000..ff7f95f60 --- /dev/null +++ b/packages/backfill/README.md @@ -0,0 +1,58 @@ + + +@fedify/backfill: ActivityPub backfill for Fedify +================================================= + +[![JSR][JSR badge]][JSR] +[![npm][npm badge]][npm] +[![Follow @fedify@hollo.social][@fedify@hollo.social badge]][@fedify@hollo.social] + +*This package is available since Fedify 2.3.0.* + +This package provides the scaffold for ActivityPub backfill support in the +[Fedify] ecosystem. It is intended to host APIs for retrieving and processing +historical federated content, but the implementation has not been added yet. + +[JSR badge]: https://jsr.io/badges/@fedify/backfill +[JSR]: https://jsr.io/@fedify/backfill +[npm badge]: https://img.shields.io/npm/v/@fedify/backfill?logo=npm +[npm]: https://www.npmjs.com/package/@fedify/backfill +[@fedify@hollo.social badge]: https://fedi-badge.deno.dev/@fedify@hollo.social/followers.svg +[@fedify@hollo.social]: https://hollo.social/@fedify +[Fedify]: https://fedify.dev/ + + +Installation +------------ + +::: code-group + +~~~~ sh [Deno] +deno add jsr:@fedify/backfill +~~~~ + +~~~~ sh [npm] +npm add @fedify/backfill +~~~~ + +~~~~ sh [pnpm] +pnpm add @fedify/backfill +~~~~ + +~~~~ sh [Yarn] +yarn add @fedify/backfill +~~~~ + +~~~~ sh [Bun] +bun add @fedify/backfill +~~~~ + +::: + + +Status +------ + +The package structure and publishing metadata are in place. Public runtime +APIs will be added in follow-up changes once the backfill workflow and data +model are finalized. diff --git a/packages/backfill/deno.json b/packages/backfill/deno.json new file mode 100644 index 000000000..cd151e493 --- /dev/null +++ b/packages/backfill/deno.json @@ -0,0 +1,22 @@ +{ + "name": "@fedify/backfill", + "version": "2.3.0", + "license": "MIT", + "exports": { + ".": "./src/mod.ts" + }, + "exclude": [ + "dist/", + "node_modules/" + ], + "publish": { + "exclude": [ + "**/*.test.ts", + "tsdown.config.ts" + ] + }, + "tasks": { + "check": "deno fmt --check && deno lint && deno check src/**/*.ts", + "test": "deno test" + } +} diff --git a/packages/backfill/package.json b/packages/backfill/package.json new file mode 100644 index 000000000..3266f1a3b --- /dev/null +++ b/packages/backfill/package.json @@ -0,0 +1,66 @@ +{ + "name": "@fedify/backfill", + "version": "2.3.0", + "description": "ActivityPub backfill support for Fedify", + "keywords": [ + "Fedify", + "ActivityPub", + "Fediverse", + "Backfill" + ], + "author": { + "name": "Jiwon Kwon", + "email": "work@kwonjiwon.org" + }, + "homepage": "https://fedify.dev/", + "repository": { + "type": "git", + "url": "git+https://github.com/fedify-dev/fedify.git", + "directory": "packages/backfill" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/fedify-dev/fedify/issues" + }, + "funding": [ + "https://opencollective.com/fedify", + "https://github.com/sponsors/dahlia" + ], + "type": "module", + "main": "./dist/mod.cjs", + "module": "./dist/mod.js", + "types": "./dist/mod.d.ts", + "exports": { + ".": { + "types": { + "import": "./dist/mod.d.ts", + "require": "./dist/mod.d.cts", + "default": "./dist/mod.d.ts" + }, + "import": "./dist/mod.js", + "require": "./dist/mod.cjs", + "default": "./dist/mod.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/", + "package.json", + "README.md" + ], + "dependencies": {}, + "devDependencies": { + "tsdown": "catalog:", + "typescript": "catalog:" + }, + "scripts": { + "build:self": "tsdown", + "build": "pnpm --filter @fedify/backfill... run build:self", + "prepack": "pnpm build", + "prepublish": "pnpm build", + "pretest": "pnpm build", + "test": "cd dist/ && node --test", + "pretest:bun": "pnpm build", + "test:bun": "cd dist/ && bun test --timeout 60000" + } +} diff --git a/packages/backfill/src/mod.ts b/packages/backfill/src/mod.ts new file mode 100644 index 000000000..b98af542e --- /dev/null +++ b/packages/backfill/src/mod.ts @@ -0,0 +1,8 @@ +/** + * ActivityPub backfill support for Fedify. + * + * This package is currently a scaffold for upcoming backfill features. + * + * @module + */ +export {}; diff --git a/packages/backfill/tsdown.config.ts b/packages/backfill/tsdown.config.ts new file mode 100644 index 000000000..bf33f512d --- /dev/null +++ b/packages/backfill/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/mod.ts"], + dts: true, + format: ["esm", "cjs"], + platform: "node", + outExtensions({ format }) { + return { + js: format === "cjs" ? ".cjs" : ".js", + dts: format === "cjs" ? ".d.cts" : ".d.ts", + }; + }, +}); diff --git a/packages/fedify/README.md b/packages/fedify/README.md index 8c17b11a4..c08313151 100644 --- a/packages/fedify/README.md +++ b/packages/fedify/README.md @@ -100,6 +100,7 @@ Here is the list of packages: | [@fedify/create](/packages/create/) | | [npm][npm:@fedify/create] | Create a new Fedify project | | [@fedify/amqp](/packages/amqp/) | [JSR][jsr:@fedify/amqp] | [npm][npm:@fedify/amqp] | AMQP/RabbitMQ driver | | [@fedify/astro](/packages/astro/) | [JSR][jsr:@fedify/astro] | [npm][npm:@fedify/astro] | Astro integration | +| [@fedify/backfill](/packages/backfill/) | [JSR][jsr:@fedify/backfill] | [npm][npm:@fedify/backfill] | ActivityPub backfill support | | [@fedify/cfworkers](/packages/cfworkers/) | [JSR][jsr:@fedify/cfworkers] | [npm][npm:@fedify/cfworkers] | Cloudflare Workers integration | | [@fedify/debugger](/packages/debugger/) | [JSR][jsr:@fedify/debugger] | [npm][npm:@fedify/debugger] | Embedded ActivityPub debug dashboard | | [@fedify/denokv](/packages/denokv/) | [JSR][jsr:@fedify/denokv] | | Deno KV integration | @@ -136,6 +137,8 @@ Here is the list of packages: [npm:@fedify/amqp]: https://www.npmjs.com/package/@fedify/amqp [jsr:@fedify/astro]: https://jsr.io/@fedify/astro [npm:@fedify/astro]: https://www.npmjs.com/package/@fedify/astro +[jsr:@fedify/backfill]: https://jsr.io/@fedify/backfill +[npm:@fedify/backfill]: https://www.npmjs.com/package/@fedify/backfill [jsr:@fedify/cfworkers]: https://jsr.io/@fedify/cfworkers [npm:@fedify/cfworkers]: https://www.npmjs.com/package/@fedify/cfworkers [jsr:@fedify/debugger]: https://jsr.io/@fedify/debugger diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3eba0e1b..a15948ba8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -849,7 +849,7 @@ importers: version: 0.10.8 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -865,7 +865,16 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + typescript: + specifier: 'catalog:' + version: 6.0.3 + + packages/backfill: + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -884,7 +893,7 @@ importers: version: 0.8.71(@cloudflare/workers-types@4.20260511.1)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)) tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1020,7 +1029,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1045,7 +1054,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1079,7 +1088,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1098,7 +1107,7 @@ importers: version: 1.2.19(@types/react@19.1.8) tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1120,7 +1129,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1142,7 +1151,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1224,7 +1233,7 @@ importers: version: 4.20250617.4 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -1258,7 +1267,7 @@ importers: version: 0.5.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1277,7 +1286,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1293,7 +1302,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1333,7 +1342,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1355,7 +1364,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1386,7 +1395,7 @@ importers: version: 9.32.0(jiti@2.6.1) tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1417,7 +1426,7 @@ importers: version: link:../testing tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1442,7 +1451,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1458,7 +1467,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1489,7 +1498,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1520,7 +1529,7 @@ importers: version: '@jsr/std__async@1.0.13' tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1554,7 +1563,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1579,7 +1588,7 @@ importers: version: link:../vocab-runtime tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1598,7 +1607,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1626,7 +1635,7 @@ importers: version: '@jsr/std__async@1.0.13' tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1642,7 +1651,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1670,7 +1679,7 @@ importers: version: '@jsr/std__async@1.0.13' tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1685,7 +1694,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1740,7 +1749,7 @@ importers: version: 12.6.0 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1780,7 +1789,7 @@ importers: version: 12.6.0 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1805,7 +1814,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1836,7 +1845,7 @@ importers: version: 12.6.0 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -25494,7 +25503,7 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34): + tsdown@0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)): dependencies: ansis: 4.3.0 cac: 7.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 896419acb..9f8bf38b5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/amqp - packages/astro +- packages/backfill - packages/cfworkers - packages/cli - packages/debugger From 1e7bd343a1aadb81779c128d098c16b9e5df1f7a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 26 May 2026 15:31:24 +0900 Subject: [PATCH 02/37] Add backfill API surface Define the initial @fedify/backfill async generator API around a typed BackfillContext, note seed object, traversal options, and BackfillItem wrappers. The generator remains a stub so tests and traversal logic can be added in follow-up commits. Assisted-by: Codex:gpt-5 --- packages/backfill/package.json | 4 +- packages/backfill/src/backfill.ts | 27 ++++++++ packages/backfill/src/mod.ts | 14 +++- packages/backfill/src/types.ts | 111 ++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 packages/backfill/src/backfill.ts create mode 100644 packages/backfill/src/types.ts diff --git a/packages/backfill/package.json b/packages/backfill/package.json index 3266f1a3b..1e80dd9d8 100644 --- a/packages/backfill/package.json +++ b/packages/backfill/package.json @@ -48,7 +48,9 @@ "package.json", "README.md" ], - "dependencies": {}, + "dependencies": { + "@fedify/vocab": "workspace:*" + }, "devDependencies": { "tsdown": "catalog:", "typescript": "catalog:" diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts new file mode 100644 index 000000000..e82c9f952 --- /dev/null +++ b/packages/backfill/src/backfill.ts @@ -0,0 +1,27 @@ +import type * as vocab from "@fedify/vocab"; + +import type { + BackfillContext, + BackfillItem, + BackfillOptions, +} from "./types.ts"; + +/** + * Backfills post-like objects related to a seed object. + * + * The seed object is not yielded by default, but its ID is treated as already + * seen so it will not be yielded again if the collection contains it. + */ +export async function* backfill< + TObject extends vocab.Object = vocab.Object, +>( + context: BackfillContext, + note: TObject, + options: BackfillOptions = {}, +): AsyncGenerator, void, void> { + void context; + void note; + void options; + + yield* [] satisfies BackfillItem[]; +} diff --git a/packages/backfill/src/mod.ts b/packages/backfill/src/mod.ts index b98af542e..6ceb69ac2 100644 --- a/packages/backfill/src/mod.ts +++ b/packages/backfill/src/mod.ts @@ -1,8 +1,18 @@ /** * ActivityPub backfill support for Fedify. * - * This package is currently a scaffold for upcoming backfill features. + * This package provides async generator APIs for collecting historical + * ActivityPub objects related to a seed object. * * @module */ -export {}; +export { backfill } from "./backfill.ts"; +export type { + BackfillContext, + BackfillDocumentLoader, + BackfillDocumentLoaderOptions, + BackfillItem, + BackfillOptions, + BackfillOrigin, + BackfillStrategy, +} from "./types.ts"; diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts new file mode 100644 index 000000000..c0f50a2c2 --- /dev/null +++ b/packages/backfill/src/types.ts @@ -0,0 +1,111 @@ +import type * as vocab from "@fedify/vocab"; + +/** + * Backfill traversal strategy used to discover the returned object. + */ +export type BackfillStrategy = "context-posts"; + +/** + * Source relation that produced a backfilled object. + */ +export type BackfillOrigin = "context" | "collection"; + +/** + * Options passed to {@link BackfillDocumentLoader}. + */ +export interface BackfillDocumentLoaderOptions { + /** + * Cancellation signal for the current dereference operation. + */ + signal?: AbortSignal; +} + +/** + * Dereferences an ActivityPub object or collection IRI. + */ +export type BackfillDocumentLoader = ( + iri: URL, + options?: BackfillDocumentLoaderOptions, +) => Promise; + +/** + * Dependencies used by backfill traversal. + */ +export interface BackfillContext { + /** + * Dereferences context collections and collection item IRIs. + */ + documentLoader: BackfillDocumentLoader; +} + +/** + * Controls direct context collection backfill traversal. + */ +export interface BackfillOptions< + TObject extends vocab.Object = vocab.Object, +> { + /** + * Maximum number of items to yield. Skipped duplicates do not count. + */ + maxItems?: number; + + /** + * Maximum traversal depth. This is reserved for future reply-tree traversal; + */ + 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. + */ + maxRequests?: number; + + /** + * Delay between `documentLoader` requests. + * + * When a callback is provided, `iteration` is the zero-based request index. + */ + interval?: + | Temporal.DurationLike + | ((iteration: number) => Temporal.DurationLike); + + /** + * Cancels traversal before requests and before yields. + */ + signal?: AbortSignal; +} + +/** + * A single object discovered by backfill traversal. + */ +export interface BackfillItem< + TObject extends vocab.Object = vocab.Object, +> { + /** + * The discovered ActivityPub object. + */ + object: TObject; + + /** + * The object's ActivityPub ID, when present. + */ + id?: URL; + + /** + * The traversal strategy that produced this item. + */ + strategy: BackfillStrategy; + + /** + * The source relation that produced this item. + */ + origin: BackfillOrigin; + + /** + * Traversal depth. Direct context collection items are depth 0; deeper + * values are reserved for future reply-tree traversal. + */ + depth?: number; +} From 6970c156e582ac19c6b2993521c51a56240c85d0 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 27 May 2026 16:11:51 +0900 Subject: [PATCH 03/37] Implement context collection backfill Add the initial context-posts traversal for @fedify/backfill. The implementation dereferences the seed object's context, accepts direct ActivityStreams collections and collection pages, yields post-like objects, and enforces request, item, interval, abort, and duplicate-id handling. Add tests for the PR 1 behavior across Deno, Node.js, and Bun. Assisted-by: Codex:gpt-5 --- packages/backfill/src/backfill.test.ts | 339 +++++++++++++++++++++++++ packages/backfill/src/backfill.ts | 166 +++++++++++- packages/backfill/src/types.ts | 8 +- packages/backfill/tsdown.config.ts | 41 ++- pnpm-lock.yaml | 4 + 5 files changed, 536 insertions(+), 22 deletions(-) create mode 100644 packages/backfill/src/backfill.test.ts diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts new file mode 100644 index 000000000..f36b46f74 --- /dev/null +++ b/packages/backfill/src/backfill.test.ts @@ -0,0 +1,339 @@ +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert"; +import test, { describe } from "node:test"; +import { backfill, type BackfillContext } from "./mod.ts"; +import { Collection, Create, Note } from "@fedify/vocab"; + +async function collect( + context: BackfillContext, + note: Note, + options: Parameters[2] = {}, +) { + return await Array.fromAsync(backfill(context, note, options)); +} + +describe("backfill", () => { + test("package exports backfill", () => { + strictEqual(typeof backfill, "function"); + }); + + test("context missing yields nothing", async () => { + const note = new Note({ + id: new URL("https://example.com/notes/1"), + }); + const context: BackfillContext = { + documentLoader: () => { + throw new Error("documentLoader should not be called"); + }, + }; + + deepStrictEqual(await collect(context, note), []); + }); + + test("context resolves to non-collection yields nothing", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Note({ + id: new URL("https://example.com/notes/2"), + }), + ), + }; + + deepStrictEqual(await collect(context, note), []); + }); + + test("context collection with embedded objects yields items", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const item = new Note({ + id: new URL("https://example.com/notes/2"), + content: "hello", + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [item], + }), + ), + }; + + const items = await collect(context, note); + + strictEqual(items.length, 1); + strictEqual(items[0].object, item); + deepStrictEqual(items[0].id, item.id); + strictEqual(items[0].strategy, "context-posts"); + strictEqual(items[0].origin, "collection"); + }); + + test("embedded object without id is yielded without id", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const item = new Note({ content: "anonymous" }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [item], + }), + ), + }; + + const items = await collect(context, note); + + strictEqual(items.length, 1); + strictEqual(items[0].object, item); + strictEqual(items[0].id, undefined); + }); + + test("activity objects in collection are skipped", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + object: new Note({ id: new URL("https://example.com/notes/2") }), + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [activity], + }), + ), + }; + + deepStrictEqual(await collect(context, note), []); + }); + + test("context collection with URL items loads and yields objects", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const itemId = new URL("https://example.com/notes/2"); + const item = new Note({ + id: itemId, + content: "hello", + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + 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: [itemId], + }), + ); + } + if (iri.href === itemId.href) return Promise.resolve(item); + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note); + + strictEqual(items.length, 1); + ok(items[0].id instanceof URL); + strictEqual(items[0].id.href, itemId.href); + deepStrictEqual(requests.map((url) => url.href), [ + contextId.href, + itemId.href, + ]); + }); + + test("seed is not yielded again when present in collection", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const other = new Note({ + id: new URL("https://example.com/notes/2"), + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [note, other], + }), + ), + }; + + const items = await collect(context, note); + + strictEqual(items.length, 1); + strictEqual(items[0].object, other); + }); + + test("duplicate object IDs are skipped", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const duplicateId = new URL("https://example.com/notes/2"); + const first = new Note({ id: duplicateId, content: "first" }); + const second = new Note({ id: duplicateId, content: "second" }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [first, second], + }), + ), + }; + + const items = await collect(context, note); + + strictEqual(items.length, 1); + strictEqual(items[0].object, first); + }); + + test("maxItems limits yielded items", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [ + new Note({ id: new URL("https://example.com/notes/2") }), + new Note({ id: new URL("https://example.com/notes/3") }), + ], + }), + ), + }; + + const items = await collect(context, note, { maxItems: 1 }); + + strictEqual(items.length, 1); + strictEqual(items[0].id?.href, "https://example.com/notes/2"); + }); + + test("maxRequests limits dereferencing", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const itemId = new URL("https://example.com/notes/2"); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: (iri) => { + if (iri.href === contextId.href) { + return Promise.resolve( + new Collection({ + id: contextId, + items: [itemId], + }), + ); + } + return Promise.resolve(new Note({ id: iri })); + }, + }; + + deepStrictEqual(await collect(context, note, { maxRequests: 1 }), []); + }); + + test("AbortSignal stops traversal", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const controller = new AbortController(); + controller.abort(); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [new Note({ id: new URL("https://example.com/notes/2") })], + }), + ), + }; + + await rejects( + collect(context, note, { signal: controller.signal }), + { name: "AbortError" }, + ); + }); + + test("documentLoader receives AbortSignal", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const controller = new AbortController(); + let receivedSignal: AbortSignal | undefined; + const context: BackfillContext = { + documentLoader: (_iri, options) => { + receivedSignal = options?.signal; + return Promise.resolve(new Collection({ id: contextId, items: [] })); + }, + }; + + await collect(context, note, { signal: controller.signal }); + + strictEqual(receivedSignal, controller.signal); + }); + + test("interval callback receives zero-based request index", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const itemId = new URL("https://example.com/notes/2"); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const iterations: number[] = []; + const context: BackfillContext = { + documentLoader: (iri) => { + if (iri.href === contextId.href) { + return Promise.resolve( + new Collection({ + id: contextId, + items: [itemId], + }), + ); + } + return Promise.resolve(new Note({ id: iri })); + }, + }; + + await collect(context, note, { + interval: (iteration) => { + iterations.push(iteration); + return { milliseconds: 0 }; + }, + }); + + deepStrictEqual(iterations, [0, 1]); + }); +}); diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index e82c9f952..fcb27f903 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -1,4 +1,12 @@ -import type * as vocab from "@fedify/vocab"; +import { + Activity, + Collection, + CollectionPage, + type Link, + Object as APObject, + OrderedCollection, + OrderedCollectionPage, +} from "@fedify/vocab"; import type { BackfillContext, @@ -6,6 +14,13 @@ import type { BackfillOptions, } from "./types.ts"; +class MaxRequestsExceeded extends Error {} + +interface RequestBudget { + readonly signal?: AbortSignal; + requestCount: number; +} + /** * Backfills post-like objects related to a seed object. * @@ -13,15 +28,154 @@ import type { * seen so it will not be yielded again if the collection contains it. */ export async function* backfill< - TObject extends vocab.Object = vocab.Object, + TObject extends APObject = APObject, >( context: BackfillContext, note: TObject, options: BackfillOptions = {}, ): AsyncGenerator, void, void> { - void context; - void note; - void options; + if (options.maxItems != null && options.maxItems <= 0) return; + + const contextId = note.contextIds[0]; + if (contextId == null) return; + + const budget: RequestBudget = { + signal: options.signal, + requestCount: 0, + }; + 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) + ) { + if (!isContextPostObject(object)) continue; + const id = object.id ?? undefined; + if (id != null) { + if (seenIds.has(id.href)) continue; + seenIds.add(id.href); + } + + options.signal?.throwIfAborted(); + yield { + object: object as TObject, + id, + strategy: "context-posts", + origin: "collection", + depth: 0, + }; + + yielded++; + if (options.maxItems != null && yielded >= options.maxItems) return; + } + } catch (error) { + if (error instanceof MaxRequestsExceeded) return; + throw error; + } +} + +async function* getCollectionItems( + context: BackfillContext, + collection: BackfillCollection, + options: BackfillOptions, + budget: RequestBudget, +): AsyncIterable { + yield* collection.getItems({ + documentLoader: async (url) => { + const object = await loadObject( + context, + new URL(url), + options, + budget, + true, + ); + if (object == null) throw new MaxRequestsExceeded(); + return { + contextUrl: null, + documentUrl: url, + document: await object.toJsonLd(), + }; + }, + crossOrigin: "trust", + }); +} + +async function loadObject( + context: BackfillContext, + iri: URL, + options: BackfillOptions, + budget: RequestBudget, + throwOnBudgetExceeded = false, +): Promise { + budget.signal?.throwIfAborted(); + if ( + options.maxRequests != null && + budget.requestCount >= options.maxRequests + ) { + if (throwOnBudgetExceeded) throw new MaxRequestsExceeded(); + return null; + } + + await waitForInterval(options, budget); + budget.signal?.throwIfAborted(); + + budget.requestCount++; + return await context.documentLoader(iri, { signal: budget.signal }); +} + +async function waitForInterval( + options: BackfillOptions, + budget: RequestBudget, +): Promise { + if (options.interval == null) return; + const duration = typeof options.interval === "function" + ? options.interval(budget.requestCount) + : options.interval; + const milliseconds = durationToMilliseconds(duration); + if (milliseconds <= 0) return; + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, milliseconds); + budget.signal?.addEventListener("abort", () => { + clearTimeout(timeout); + reject(budget.signal?.reason); + }, { once: true }); + }); +} + +function durationToMilliseconds(duration: Temporal.DurationLike): number { + return ( + (duration.milliseconds ?? 0) + + (duration.seconds ?? 0) * 1000 + + (duration.minutes ?? 0) * 60 * 1000 + + (duration.hours ?? 0) * 60 * 60 * 1000 + + (duration.days ?? 0) * 24 * 60 * 60 * 1000 + ); +} + +type BackfillCollection = + | Collection + | OrderedCollection + | CollectionPage + | OrderedCollectionPage; + +function isCollection( + object: APObject | null, +): object is BackfillCollection { + return object instanceof Collection || + object instanceof OrderedCollection || + object instanceof CollectionPage || + object instanceof OrderedCollectionPage; +} - yield* [] satisfies BackfillItem[]; +function isContextPostObject( + object: APObject | Link, +): object is APObject { + return object instanceof APObject && + !(object instanceof Activity) && + !isCollection(object); } diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts index c0f50a2c2..bcf82f004 100644 --- a/packages/backfill/src/types.ts +++ b/packages/backfill/src/types.ts @@ -1,4 +1,4 @@ -import type * as vocab from "@fedify/vocab"; +import type { Object as APObject } from "@fedify/vocab"; /** * Backfill traversal strategy used to discover the returned object. @@ -26,7 +26,7 @@ export interface BackfillDocumentLoaderOptions { export type BackfillDocumentLoader = ( iri: URL, options?: BackfillDocumentLoaderOptions, -) => Promise; +) => Promise; /** * Dependencies used by backfill traversal. @@ -42,7 +42,7 @@ export interface BackfillContext { * Controls direct context collection backfill traversal. */ export interface BackfillOptions< - TObject extends vocab.Object = vocab.Object, + TObject extends APObject = APObject, > { /** * Maximum number of items to yield. Skipped duplicates do not count. @@ -81,7 +81,7 @@ export interface BackfillOptions< * A single object discovered by backfill traversal. */ export interface BackfillItem< - TObject extends vocab.Object = vocab.Object, + TObject extends APObject = APObject, > { /** * The discovered ActivityPub object. diff --git a/packages/backfill/tsdown.config.ts b/packages/backfill/tsdown.config.ts index bf33f512d..9f43a88ac 100644 --- a/packages/backfill/tsdown.config.ts +++ b/packages/backfill/tsdown.config.ts @@ -1,14 +1,31 @@ +import { glob } from "node:fs/promises"; +import { sep } from "node:path"; import { defineConfig } from "tsdown"; -export default defineConfig({ - entry: ["src/mod.ts"], - dts: true, - format: ["esm", "cjs"], - platform: "node", - outExtensions({ format }) { - return { - js: format === "cjs" ? ".cjs" : ".js", - dts: format === "cjs" ? ".d.cts" : ".d.ts", - }; - }, -}); +export default [ + defineConfig({ + entry: ["src/mod.ts"], + dts: true, + format: ["esm", "cjs"], + platform: "node", + outExtensions({ format }) { + return { + js: format === "cjs" ? ".cjs" : ".js", + dts: format === "cjs" ? ".d.cts" : ".d.ts", + }; + }, + }), + defineConfig({ + entry: (await Array.fromAsync(glob(`src/**/*.test.ts`))) + .map((f) => f.replace(sep, "/")), + format: ["esm", "cjs"], + platform: "node", + outExtensions({ format }) { + return { + js: format === "cjs" ? ".cjs" : ".js", + dts: format === "cjs" ? ".d.cts" : ".d.ts", + }; + }, + deps: { neverBundle: [/^node:/] }, + }), +]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a15948ba8..0dcee0d3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -871,6 +871,10 @@ importers: version: 6.0.3 packages/backfill: + dependencies: + '@fedify/vocab': + specifier: workspace:* + version: link:../vocab devDependencies: tsdown: specifier: 'catalog:' From 392730b89211293dea8c263e4b061d92bfcb9511 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 27 May 2026 16:27:08 +0900 Subject: [PATCH 04/37] Document backfill API usage Replace the scaffold status text with a short description of the initial context collection backfill behavior and a minimal usage example. Assisted-by: Codex:gpt-5 --- packages/backfill/README.md | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/backfill/README.md b/packages/backfill/README.md index ff7f95f60..3d38a762c 100644 --- a/packages/backfill/README.md +++ b/packages/backfill/README.md @@ -9,9 +9,11 @@ *This package is available since Fedify 2.3.0.* -This package provides the scaffold for ActivityPub backfill support in the -[Fedify] ecosystem. It is intended to host APIs for retrieving and processing -historical federated content, but the implementation has not been added yet. +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`. [JSR badge]: https://jsr.io/badges/@fedify/backfill [JSR]: https://jsr.io/@fedify/backfill @@ -50,9 +52,28 @@ bun add @fedify/backfill ::: -Status ------- +Usage +----- -The package structure and publishing metadata are in place. Public runtime -APIs will be added in follow-up changes once the backfill workflow and data -model are finalized. +The `backfill()` function accepts a backfill context, a seed object, and +traversal options: + +~~~~ typescript +import { backfill } from "@fedify/backfill"; +import { lookupObject } from "@fedify/vocab"; + +const documentLoader = (iri: URL, options?: { signal?: AbortSignal }) => + lookupObject(iri, { signal: options?.signal }); + +for await ( + const item of backfill({ documentLoader }, note, { + maxItems: 20, + maxRequests: 50, + }) +) { + console.log(item.id?.href); +} +~~~~ + +The seed object itself is not yielded. If it appears in the discovered +collection, it is skipped by ID. From ff8f75ffb05783b1f714820c8108df2640eb2dd0 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 27 May 2026 16:35:58 +0900 Subject: [PATCH 05/37] Clean up backfill lockfile changes Remove unrelated lockfile churn from the backfill branch, keeping only the new package importer required for @fedify/backfill. Assisted-by: Codex:gpt-5 --- deno.lock | 13 ++++++++++ pnpm-lock.yaml | 68 +++++++++++++++++++++++++------------------------- 2 files changed, 47 insertions(+), 34 deletions(-) diff --git a/deno.lock b/deno.lock index f7cd75f23..bef73340b 100644 --- a/deno.lock +++ b/deno.lock @@ -84,6 +84,7 @@ "npm:@jimp/core@^1.6.1": "1.6.1", "npm:@jimp/wasm-webp@^1.6.1": "1.6.1", "npm:@js-temporal/polyfill@~0.5.1": "0.5.1", + "npm:@jsr/std__assert@0.226": "0.226.0", "npm:@multiformats/base-x@^4.0.1": "4.0.1", "npm:@nestjs/common@^11.0.1": "11.1.19_reflect-metadata@0.2.2_rxjs@7.8.2", "npm:@nurodev/astro-bun@^2.1.2": "2.1.2_astro@5.18.1__@types+node@24.12.2__mysql2@3.22.3___@types+node@24.12.2_@types+node@24.12.2_mysql2@3.22.3__@types+node@24.12.2", @@ -2381,6 +2382,17 @@ "wasm-feature-detect" ] }, + "@jsr/std__assert@0.226.0": { + "integrity": "sha512-xCuUFDfHkIZd96glKgjZbnYFqu6blu8Y53SyvDMlFDJm1Y/j+/FcW6xq7TzGFIaF5B9QecIlDfamfhzA8ZdVbg==", + "dependencies": [ + "@jsr/std__internal" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__assert/0.226.0.tgz" + }, + "@jsr/std__internal@1.0.12": { + "integrity": "sha512-6xReMW9p+paJgqoFRpOE2nogJFvzPfaLHLIlyADYjKMUcwDyjKZxryIbgcU+gxiTygn8yCjld1HoI0ET4/iZeA==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz" + }, "@logtape/logtape@1.3.7": { "integrity": "sha512-YgF+q9op97oLLPwc7TcTNIllTArVtTwkwyKky6XVzAXQcBrvFXXtMuwJSryONAyOUSItrx994O/HABOrszZyFg==" }, @@ -9460,6 +9472,7 @@ "packageJson": { "dependencies": [ "npm:@js-temporal/polyfill@~0.5.1", + "npm:@jsr/std__assert@0.226", "npm:@types/node@^24.2.1", "npm:json-canon@^1.0.1", "npm:jsonld@9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dcee0d3e..df3689951 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -849,7 +849,7 @@ importers: version: 0.10.8 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -865,7 +865,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -878,7 +878,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -897,7 +897,7 @@ importers: version: 0.8.71(@cloudflare/workers-types@4.20260511.1)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)) tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1033,7 +1033,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1058,7 +1058,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1092,7 +1092,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1111,7 +1111,7 @@ importers: version: 1.2.19(@types/react@19.1.8) tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1133,7 +1133,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1155,7 +1155,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1237,7 +1237,7 @@ importers: version: 4.20250617.4 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -1271,7 +1271,7 @@ importers: version: 0.5.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1290,7 +1290,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1306,7 +1306,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1346,7 +1346,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1368,7 +1368,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1399,7 +1399,7 @@ importers: version: 9.32.0(jiti@2.6.1) tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1430,7 +1430,7 @@ importers: version: link:../testing tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1455,7 +1455,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1471,7 +1471,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1502,7 +1502,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1533,7 +1533,7 @@ importers: version: '@jsr/std__async@1.0.13' tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1567,7 +1567,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1592,7 +1592,7 @@ importers: version: link:../vocab-runtime tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1611,7 +1611,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1639,7 +1639,7 @@ importers: version: '@jsr/std__async@1.0.13' tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1655,7 +1655,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1683,7 +1683,7 @@ importers: version: '@jsr/std__async@1.0.13' tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1698,7 +1698,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1753,7 +1753,7 @@ importers: version: 12.6.0 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1793,7 +1793,7 @@ importers: version: 12.6.0 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1818,7 +1818,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1849,7 +1849,7 @@ importers: version: 12.6.0 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) typescript: specifier: 'catalog:' version: 6.0.3 @@ -25507,7 +25507,7 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)): + tsdown@0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34): dependencies: ansis: 4.3.0 cac: 7.0.0 From a9cb0db721803ae5f66a8e3dec79007853104c14 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 27 May 2026 17:14:31 +0900 Subject: [PATCH 06/37] Address backfill review feedback Skip collection URL items that fail to dereference instead of terminating the whole traversal, while still stopping when the request budget is exhausted. Also clean up interval abort listeners after successful waits. Assisted-by: Codex:gpt-5 --- packages/backfill/src/backfill.test.ts | 38 +++++++++++++++++++ packages/backfill/src/backfill.ts | 52 ++++++++++++++++++++------ 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index f36b46f74..4170f34be 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -162,6 +162,44 @@ describe("backfill", () => { ]); }); + 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"); + const failedItemId = new URL("https://example.com/notes/failed"); + const itemId = new URL("https://example.com/notes/2"); + const item = new Note({ + id: itemId, + content: "hello", + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: (iri) => { + if (iri.href === contextId.href) { + return Promise.resolve( + new Collection({ + id: contextId, + items: [missingItemId, failedItemId, itemId], + }), + ); + } + if (iri.href === missingItemId.href) return Promise.resolve(null); + if (iri.href === failedItemId.href) { + return Promise.reject(new Error("failed to load")); + } + if (iri.href === itemId.href) return Promise.resolve(item); + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note); + + strictEqual(items.length, 1); + strictEqual(items[0].id?.href, itemId.href); + }); + test("seed is not yielded again when present in collection", async () => { const contextId = new URL("https://example.com/contexts/1"); const note = new Note({ diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index fcb27f903..c91f63e15 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -87,14 +87,21 @@ async function* getCollectionItems( ): AsyncIterable { yield* collection.getItems({ documentLoader: async (url) => { - const object = await loadObject( - context, - new URL(url), - options, - budget, - true, - ); - if (object == null) throw new MaxRequestsExceeded(); + let object: APObject | null; + try { + object = await loadObject( + context, + new URL(url), + options, + budget, + true, + ); + } catch (error) { + if (error instanceof MaxRequestsExceeded) throw error; + budget.signal?.throwIfAborted(); + return skippedCollectionItemDocument(url); + } + if (object == null) return skippedCollectionItemDocument(url); return { contextUrl: null, documentUrl: url, @@ -105,6 +112,17 @@ async function* getCollectionItems( }); } +function skippedCollectionItemDocument(url: string) { + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Activity", + }, + }; +} + async function loadObject( context: BackfillContext, iri: URL, @@ -139,15 +157,27 @@ async function waitForInterval( const milliseconds = durationToMilliseconds(duration); if (milliseconds <= 0) return; await new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, milliseconds); - budget.signal?.addEventListener("abort", () => { + if (budget.signal?.aborted) { + reject(budget.signal.reason); + return; + } + const timeout = setTimeout(() => { + budget.signal?.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + const onAbort = () => { clearTimeout(timeout); reject(budget.signal?.reason); - }, { once: true }); + }; + budget.signal?.addEventListener("abort", onAbort, { once: true }); }); } function durationToMilliseconds(duration: Temporal.DurationLike): number { + if (typeof duration === "string") { + return Temporal.Duration.from(duration).total({ unit: "milliseconds" }); + } + return ( (duration.milliseconds ?? 0) + (duration.seconds ?? 0) * 1000 + From cdcb0660b65187b88dd363b1c4262d2831f7e723 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 27 May 2026 20:09:39 +0900 Subject: [PATCH 07/37] Normalize backfill test entry paths Normalize every platform path separator in globbed test entries before passing those paths to tsdown. Assisted-by: Codex:gpt-5 --- packages/backfill/tsdown.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backfill/tsdown.config.ts b/packages/backfill/tsdown.config.ts index 9f43a88ac..c9ddde870 100644 --- a/packages/backfill/tsdown.config.ts +++ b/packages/backfill/tsdown.config.ts @@ -17,7 +17,7 @@ export default [ }), defineConfig({ entry: (await Array.fromAsync(glob(`src/**/*.test.ts`))) - .map((f) => f.replace(sep, "/")), + .map((f) => f.replaceAll(sep, "/")), format: ["esm", "cjs"], platform: "node", outExtensions({ format }) { From 3f87eaabfda208ad92bac36f55c17630b89eade7 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 1 Jun 2026 18:32:03 +0900 Subject: [PATCH 08/37] Guard string interval without Temporal When callers pass a string interval, report a clear error if Temporal is not available globally instead of failing with an unhelpful ReferenceError. Assisted-by: Codex:gpt-5 --- packages/backfill/src/backfill.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index c91f63e15..cbc7164b0 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -175,6 +175,13 @@ async function waitForInterval( function durationToMilliseconds(duration: Temporal.DurationLike): number { if (typeof duration === "string") { + 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.", + ); + } return Temporal.Duration.from(duration).total({ unit: "milliseconds" }); } From 0ad8d5d5399126e65a3e140ce57bbba29527d491 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 2 Jun 2026 16:47:38 +0900 Subject: [PATCH 09/37] Address backfill review details Make the interval option explicitly accept string durations, keep the internal duration helper aligned with that public type, and use strict Node assertions in the backfill tests. Explicitly externalize @fedify/vocab in the backfill tsdown build so vocabulary class identity remains stable for instanceof checks. Assisted-by: Codex:gpt-5 --- packages/backfill/src/backfill.test.ts | 2 +- packages/backfill/src/backfill.ts | 4 +++- packages/backfill/src/types.ts | 3 ++- packages/backfill/tsdown.config.ts | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index 4170f34be..9c827ee92 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -1,4 +1,4 @@ -import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert"; +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; import test, { describe } from "node:test"; import { backfill, type BackfillContext } from "./mod.ts"; import { Collection, Create, Note } from "@fedify/vocab"; diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index cbc7164b0..b0bf00cea 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -173,7 +173,9 @@ async function waitForInterval( }); } -function durationToMilliseconds(duration: Temporal.DurationLike): number { +function durationToMilliseconds( + duration: Temporal.DurationLike | string, +): number { if (typeof duration === "string") { if (typeof Temporal === "undefined") { throw new TypeError( diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts index bcf82f004..b6db32be8 100644 --- a/packages/backfill/src/types.ts +++ b/packages/backfill/src/types.ts @@ -69,7 +69,8 @@ export interface BackfillOptions< */ interval?: | Temporal.DurationLike - | ((iteration: number) => Temporal.DurationLike); + | string + | ((iteration: number) => Temporal.DurationLike | string); /** * Cancels traversal before requests and before yields. diff --git a/packages/backfill/tsdown.config.ts b/packages/backfill/tsdown.config.ts index c9ddde870..bd0f4b7a0 100644 --- a/packages/backfill/tsdown.config.ts +++ b/packages/backfill/tsdown.config.ts @@ -14,6 +14,7 @@ export default [ dts: format === "cjs" ? ".d.cts" : ".d.ts", }; }, + deps: { neverBundle: ["@fedify/vocab"] }, }), defineConfig({ entry: (await Array.fromAsync(glob(`src/**/*.test.ts`))) @@ -26,6 +27,6 @@ export default [ dts: format === "cjs" ? ".d.cts" : ".d.ts", }; }, - deps: { neverBundle: [/^node:/] }, + deps: { neverBundle: [/^node:/, "@fedify/vocab"] }, }), ]; From bdf65df6d3b7cef4513dbf727ad05ee9772a5d75 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 2 Jun 2026 21:43:02 +0900 Subject: [PATCH 10/37] Polish backfill public API docs Replace the package README's VitePress-only installation block with plain Markdown, document the new public API surface with since tags, and mark exported option and result properties as readonly. Export the request-budget error class so callers can identify it when it escapes traversal internals. Assisted-by: Codex:gpt-5 --- packages/backfill/README.md | 26 ++++-------------- packages/backfill/src/backfill.test.ts | 3 +- packages/backfill/src/backfill.ts | 9 +++++- packages/backfill/src/mod.ts | 2 +- packages/backfill/src/types.ts | 38 ++++++++++++++++++-------- 5 files changed, 42 insertions(+), 36 deletions(-) diff --git a/packages/backfill/README.md b/packages/backfill/README.md index 3d38a762c..7c8a4ceab 100644 --- a/packages/backfill/README.md +++ b/packages/backfill/README.md @@ -27,30 +27,14 @@ or `OrderedCollectionPage`. Installation ------------ -::: code-group - -~~~~ sh [Deno] +~~~~ sh deno add jsr:@fedify/backfill +npm add @fedify/backfill +pnpm add @fedify/backfill +yarn add @fedify/backfill +bun add @fedify/backfill ~~~~ -~~~~ sh [npm] -npm add @fedify/backfill -~~~~ - -~~~~ sh [pnpm] -pnpm add @fedify/backfill -~~~~ - -~~~~ sh [Yarn] -yarn add @fedify/backfill -~~~~ - -~~~~ sh [Bun] -bun add @fedify/backfill -~~~~ - -::: - Usage ----- diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index 9c827ee92..fe957187d 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -1,6 +1,6 @@ import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; import test, { describe } from "node:test"; -import { backfill, type BackfillContext } from "./mod.ts"; +import { backfill, type BackfillContext, MaxRequestsExceeded } from "./mod.ts"; import { Collection, Create, Note } from "@fedify/vocab"; async function collect( @@ -14,6 +14,7 @@ async function collect( describe("backfill", () => { test("package exports backfill", () => { strictEqual(typeof backfill, "function"); + strictEqual(typeof MaxRequestsExceeded, "function"); }); test("context missing yields nothing", async () => { diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index b0bf00cea..ffee632ae 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -14,7 +14,12 @@ import type { BackfillOptions, } from "./types.ts"; -class MaxRequestsExceeded extends Error {} +/** + * Thrown when backfill traversal exceeds the configured request budget. + * + * @since 2.3.0 + */ +export class MaxRequestsExceeded extends Error {} interface RequestBudget { readonly signal?: AbortSignal; @@ -26,6 +31,8 @@ interface RequestBudget { * * The seed object is not yielded by default, but its ID is treated as already * seen so it will not be yielded again if the collection contains it. + * + * @since 2.3.0 */ export async function* backfill< TObject extends APObject = APObject, diff --git a/packages/backfill/src/mod.ts b/packages/backfill/src/mod.ts index 6ceb69ac2..d72c3c339 100644 --- a/packages/backfill/src/mod.ts +++ b/packages/backfill/src/mod.ts @@ -6,7 +6,7 @@ * * @module */ -export { backfill } from "./backfill.ts"; +export { backfill, MaxRequestsExceeded } from "./backfill.ts"; export type { BackfillContext, BackfillDocumentLoader, diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts index b6db32be8..03d39872f 100644 --- a/packages/backfill/src/types.ts +++ b/packages/backfill/src/types.ts @@ -2,26 +2,34 @@ import type { Object as APObject } from "@fedify/vocab"; /** * Backfill traversal strategy used to discover the returned object. + * + * @since 2.3.0 */ export type BackfillStrategy = "context-posts"; /** * Source relation that produced a backfilled object. + * + * @since 2.3.0 */ export type BackfillOrigin = "context" | "collection"; /** * Options passed to {@link BackfillDocumentLoader}. + * + * @since 2.3.0 */ export interface BackfillDocumentLoaderOptions { /** * Cancellation signal for the current dereference operation. */ - signal?: AbortSignal; + readonly signal?: AbortSignal; } /** * Dereferences an ActivityPub object or collection IRI. + * + * @since 2.3.0 */ export type BackfillDocumentLoader = ( iri: URL, @@ -30,16 +38,20 @@ export type BackfillDocumentLoader = ( /** * Dependencies used by backfill traversal. + * + * @since 2.3.0 */ export interface BackfillContext { /** * Dereferences context collections and collection item IRIs. */ - documentLoader: BackfillDocumentLoader; + readonly documentLoader: BackfillDocumentLoader; } /** * Controls direct context collection backfill traversal. + * + * @since 2.3.0 */ export interface BackfillOptions< TObject extends APObject = APObject, @@ -47,12 +59,12 @@ export interface BackfillOptions< /** * Maximum number of items to yield. Skipped duplicates do not count. */ - maxItems?: number; + readonly maxItems?: number; /** * Maximum traversal depth. This is reserved for future reply-tree traversal; */ - maxDepth?: number; + readonly maxDepth?: number; /** * Maximum number of calls to {@link BackfillContext.documentLoader}. @@ -60,14 +72,14 @@ export interface BackfillOptions< * Dereferencing the note context, collection item IRIs, and future page IRIs * all count as requests. Embedded collection items do not count. */ - maxRequests?: number; + readonly maxRequests?: number; /** * Delay between `documentLoader` requests. * * When a callback is provided, `iteration` is the zero-based request index. */ - interval?: + readonly interval?: | Temporal.DurationLike | string | ((iteration: number) => Temporal.DurationLike | string); @@ -75,11 +87,13 @@ export interface BackfillOptions< /** * Cancels traversal before requests and before yields. */ - signal?: AbortSignal; + readonly signal?: AbortSignal; } /** * A single object discovered by backfill traversal. + * + * @since 2.3.0 */ export interface BackfillItem< TObject extends APObject = APObject, @@ -87,26 +101,26 @@ export interface BackfillItem< /** * The discovered ActivityPub object. */ - object: TObject; + readonly object: TObject; /** * The object's ActivityPub ID, when present. */ - id?: URL; + readonly id?: URL; /** * The traversal strategy that produced this item. */ - strategy: BackfillStrategy; + readonly strategy: BackfillStrategy; /** * The source relation that produced this item. */ - origin: BackfillOrigin; + readonly origin: BackfillOrigin; /** * Traversal depth. Direct context collection items are depth 0; deeper * values are reserved for future reply-tree traversal. */ - depth?: number; + readonly depth?: number; } From 5d2a8ed19be739dfa58320530553cdb54b0798ae Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 2 Jun 2026 22:43:22 +0900 Subject: [PATCH 11/37] Use provisional backfill since tags Keep the new backfill API documentation on provisional 2.x.0 since tags until the feature branch is ready to merge and the target release version is known. Assisted-by: Codex:gpt-5 --- packages/backfill/src/backfill.ts | 4 ++-- packages/backfill/src/types.ts | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index ffee632ae..5e3968b82 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -17,7 +17,7 @@ import type { /** * Thrown when backfill traversal exceeds the configured request budget. * - * @since 2.3.0 + * @since 2.x.0 */ export class MaxRequestsExceeded extends Error {} @@ -32,7 +32,7 @@ interface RequestBudget { * The seed object is not yielded by default, but its ID is treated as already * seen so it will not be yielded again if the collection contains it. * - * @since 2.3.0 + * @since 2.x.0 */ export async function* backfill< TObject extends APObject = APObject, diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts index 03d39872f..25555f30e 100644 --- a/packages/backfill/src/types.ts +++ b/packages/backfill/src/types.ts @@ -3,21 +3,21 @@ import type { Object as APObject } from "@fedify/vocab"; /** * Backfill traversal strategy used to discover the returned object. * - * @since 2.3.0 + * @since 2.x.0 */ export type BackfillStrategy = "context-posts"; /** * Source relation that produced a backfilled object. * - * @since 2.3.0 + * @since 2.x.0 */ export type BackfillOrigin = "context" | "collection"; /** * Options passed to {@link BackfillDocumentLoader}. * - * @since 2.3.0 + * @since 2.x.0 */ export interface BackfillDocumentLoaderOptions { /** @@ -29,7 +29,7 @@ export interface BackfillDocumentLoaderOptions { /** * Dereferences an ActivityPub object or collection IRI. * - * @since 2.3.0 + * @since 2.x.0 */ export type BackfillDocumentLoader = ( iri: URL, @@ -39,7 +39,7 @@ export type BackfillDocumentLoader = ( /** * Dependencies used by backfill traversal. * - * @since 2.3.0 + * @since 2.x.0 */ export interface BackfillContext { /** @@ -51,7 +51,7 @@ export interface BackfillContext { /** * Controls direct context collection backfill traversal. * - * @since 2.3.0 + * @since 2.x.0 */ export interface BackfillOptions< TObject extends APObject = APObject, @@ -93,7 +93,7 @@ export interface BackfillOptions< /** * A single object discovered by backfill traversal. * - * @since 2.3.0 + * @since 2.x.0 */ export interface BackfillItem< TObject extends APObject = APObject, From 9eb5d602206b30bfa4e82caba25e086ec8f29f27 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 10 Jun 2026 00:27:29 +0900 Subject: [PATCH 12/37] Add context activity backfill Add context-auto, context-objects, and context-activities strategy handling for context collection backfill. The auto strategy classifies ordinary objects directly and extracts objects from supported Create activities while preserving shared request budgets, deduplication, abort, and interval behavior. Document the strategy behavior and add coverage for explicit, automatic, empty, overlapping, and duplicate strategy configurations. Assisted-by: Codex:gpt-5.5 --- packages/backfill/README.md | 20 ++ packages/backfill/src/backfill.test.ts | 459 ++++++++++++++++++++++++- packages/backfill/src/backfill.ts | 169 +++++++-- packages/backfill/src/types.ts | 14 +- 4 files changed, 620 insertions(+), 42 deletions(-) diff --git a/packages/backfill/README.md b/packages/backfill/README.md index 7c8a4ceab..b7f905a34 100644 --- a/packages/backfill/README.md +++ b/packages/backfill/README.md @@ -61,3 +61,23 @@ for await ( The seed object itself is not yielded. If it appears in the discovered collection, it is skipped by ID. + +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 an Activity, `backfill()` extracts the activity's object instead. + +To read only FEP-f228 activity collections, enable the `context-activities` +strategy: + +~~~~ typescript +for await ( + const item of backfill({ documentLoader }, note, { + strategies: ["context-activities"], + }) +) { + console.log(item.object); +} +~~~~ + +The `context-activities` strategy currently supports `Create` activities and +yields the activity's object, not the activity itself. diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index fe957187d..12dc0928d 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -1,7 +1,7 @@ import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; import test, { describe } from "node:test"; import { backfill, type BackfillContext, MaxRequestsExceeded } from "./mod.ts"; -import { Collection, Create, Note } from "@fedify/vocab"; +import { Announce, Collection, Create, Note } from "@fedify/vocab"; async function collect( context: BackfillContext, @@ -73,10 +73,39 @@ describe("backfill", () => { strictEqual(items.length, 1); strictEqual(items[0].object, item); deepStrictEqual(items[0].id, item.id); - strictEqual(items[0].strategy, "context-posts"); + strictEqual(items[0].strategy, "context-auto"); strictEqual(items[0].origin, "collection"); }); + test("context object strategy yields embedded objects", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const item = new Note({ + id: new URL("https://example.com/notes/2"), + content: "hello", + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [item], + }), + ), + }; + + const items = await collect(context, note, { + strategies: ["context-objects"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object, item); + strictEqual(items[0].strategy, "context-objects"); + }); + test("embedded object without id is yielded without id", async () => { const contextId = new URL("https://example.com/contexts/1"); const item = new Note({ content: "anonymous" }); @@ -101,7 +130,7 @@ describe("backfill", () => { strictEqual(items[0].id, undefined); }); - test("activity objects in collection are skipped", async () => { + test("context object strategy skips activity objects", async () => { const contextId = new URL("https://example.com/contexts/1"); const activity = new Create({ id: new URL("https://example.com/activities/1"), @@ -121,7 +150,429 @@ describe("backfill", () => { ), }; - deepStrictEqual(await collect(context, note), []); + deepStrictEqual( + await collect(context, note, { strategies: ["context-objects"] }), + [], + ); + }); + + test("context auto strategy yields object from embedded Create", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const item = new Note({ + id: new URL("https://example.com/notes/2"), + content: "hello", + }); + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + object: item, + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [activity], + }), + ), + }; + + const items = await collect(context, note); + + strictEqual(items.length, 1); + strictEqual(items[0].object, item); + strictEqual(items[0].strategy, "context-auto"); + }); + + test("empty strategies yield nothing without dereferencing context", 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: [] }), []); + }); + + test("context auto overrides overlapping strategies", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const item = new Note({ content: "anonymous" }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [item], + }), + ), + }; + + const items = await collect(context, note, { + strategies: ["context-auto", "context-objects"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object, item); + strictEqual(items[0].strategy, "context-auto"); + }); + + test("duplicate strategies are ignored", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const item = new Note({ content: "anonymous" }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [item], + }), + ), + }; + + const items = await collect(context, note, { + strategies: ["context-objects", "context-objects"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object, item); + strictEqual(items[0].strategy, "context-objects"); + }); + + test("context activity collection yields object from embedded Create", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const item = new Note({ + id: new URL("https://example.com/notes/2"), + content: "hello", + }); + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + object: item, + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [activity], + }), + ), + }; + + const items = await collect(context, note, { + strategies: ["context-activities"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object, item); + strictEqual(items[0].id?.href, item.id?.href); + strictEqual(items[0].strategy, "context-activities"); + strictEqual(items[0].origin, "collection"); + }); + + test("combined context strategies yield posts and activity objects", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const post = new Note({ + id: new URL("https://example.com/notes/2"), + }); + const activityObject = new Note({ + id: new URL("https://example.com/notes/3"), + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [ + post, + new Create({ + id: new URL("https://example.com/activities/1"), + object: activityObject, + }), + ], + }), + ), + }; + + const items = await collect(context, note, { + strategies: ["context-objects", "context-activities"], + }); + + strictEqual(items.length, 2); + strictEqual(items[0].object, post); + strictEqual(items[0].strategy, "context-objects"); + strictEqual(items[1].object, activityObject); + strictEqual(items[1].strategy, "context-activities"); + }); + + 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"); + const item = new Note({ id: itemId, content: "hello" }); + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + object: itemId, + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + 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: [activity], + }), + ); + } + if (iri.href === itemId.href) return Promise.resolve(item); + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note, { + strategies: ["context-activities"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object.id?.href, item.id?.href); + deepStrictEqual(requests.map((url) => url.href), [ + contextId.href, + itemId.href, + ]); + }); + + test("context activity collection dereferences activity URL", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const activityId = new URL("https://example.com/activities/1"); + const item = new Note({ + id: new URL("https://example.com/notes/2"), + content: "hello", + }); + const activity = new Create({ id: activityId, object: item }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + 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: [activityId], + }), + ); + } + if (iri.href === activityId.href) return Promise.resolve(activity); + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note, { + strategies: ["context-activities"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object.id?.href, item.id?.href); + deepStrictEqual(requests.map((url) => url.href), [ + contextId.href, + activityId.href, + ]); + }); + + test("context activity collection deduplicates by extracted object ID", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const itemId = new URL("https://example.com/notes/2"); + const first = new Create({ + id: new URL("https://example.com/activities/1"), + object: new Note({ id: itemId, content: "first" }), + }); + const second = new Create({ + id: new URL("https://example.com/activities/2"), + object: new Note({ id: itemId, content: "second" }), + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [first, second], + }), + ), + }; + + const items = await collect(context, note, { + strategies: ["context-activities"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].id?.href, itemId.href); + }); + + test("context activity collection skips missing object", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const activity = new Create({ + id: new URL("https://example.com/activities/1"), + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [activity], + }), + ), + }; + + deepStrictEqual( + await collect(context, note, { strategies: ["context-activities"] }), + [], + ); + }); + + test("context activity collection skips unsupported activity type", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const item = new Note({ id: new URL("https://example.com/notes/2") }); + const activity = new Announce({ + id: new URL("https://example.com/activities/1"), + object: item, + }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [activity], + }), + ), + }; + + deepStrictEqual( + await collect(context, note, { strategies: ["context-activities"] }), + [], + ); + }); + + test("maxRequests limits activity object dereferencing", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const activityId = new URL("https://example.com/activities/1"); + const itemId = new URL("https://example.com/notes/2"); + const activity = new Create({ id: activityId, object: itemId }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + 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: [activityId], + }), + ); + } + if (iri.href === activityId.href) return Promise.resolve(activity); + if (iri.href === itemId.href) { + return Promise.resolve( + new Note({ + id: itemId, + }), + ); + } + return Promise.resolve(null); + }, + }; + + const items = await collect(context, note, { + maxRequests: 2, + strategies: ["context-activities"], + }); + + deepStrictEqual(items, []); + deepStrictEqual(requests.map((url) => url.href), [ + contextId.href, + activityId.href, + ]); + }); + + test("maxItems limits context activity items", async () => { + const contextId = new URL("https://example.com/contexts/1"); + const first = new Note({ id: new URL("https://example.com/notes/2") }); + const second = new Note({ id: new URL("https://example.com/notes/3") }); + const note = new Note({ + id: new URL("https://example.com/notes/1"), + contexts: [contextId], + }); + const context: BackfillContext = { + documentLoader: () => + Promise.resolve( + new Collection({ + id: contextId, + items: [ + new Create({ + id: new URL("https://example.com/activities/1"), + object: first, + }), + new Create({ + id: new URL("https://example.com/activities/2"), + object: second, + }), + ], + }), + ), + }; + + const items = await collect(context, note, { + maxItems: 1, + strategies: ["context-activities"], + }); + + strictEqual(items.length, 1); + strictEqual(items[0].id?.href, first.id?.href); }); test("context collection with URL items loads and yields objects", async () => { diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index 5e3968b82..3a0b4361d 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -2,6 +2,7 @@ import { Activity, Collection, CollectionPage, + Create, type Link, Object as APObject, OrderedCollection, @@ -12,8 +13,13 @@ import type { BackfillContext, BackfillItem, BackfillOptions, + BackfillStrategy, } from "./types.ts"; +const defaultStrategies = [ + "context-auto", +] as const satisfies readonly BackfillStrategy[]; + /** * Thrown when backfill traversal exceeds the configured request budget. * @@ -42,6 +48,8 @@ export async function* backfill< options: BackfillOptions = {}, ): AsyncGenerator, void, void> { if (options.maxItems != null && options.maxItems <= 0) return; + const strategies = normalizeStrategies(options.strategies); + if (strategies.length < 1) return; const contextId = note.contextIds[0]; if (contextId == null) return; @@ -61,24 +69,33 @@ export async function* backfill< for await ( const object of getCollectionItems(context, collection, options, budget) ) { - if (!isContextPostObject(object)) continue; - const id = object.id ?? undefined; - if (id != null) { - if (seenIds.has(id.href)) continue; - seenIds.add(id.href); - } + for await ( + const item of getBackfillItems( + context, + object, + strategies, + options, + budget, + ) + ) { + const id = item.object.id ?? undefined; + if (id != null) { + if (seenIds.has(id.href)) continue; + seenIds.add(id.href); + } + + options.signal?.throwIfAborted(); + yield { + object: item.object as TObject, + id, + strategy: item.strategy, + origin: "collection", + depth: 0, + }; - options.signal?.throwIfAborted(); - yield { - object: object as TObject, - id, - strategy: "context-posts", - origin: "collection", - depth: 0, - }; - - yielded++; - if (options.maxItems != null && yielded >= options.maxItems) return; + yielded++; + if (options.maxItems != null && yielded >= options.maxItems) return; + } } } catch (error) { if (error instanceof MaxRequestsExceeded) return; @@ -86,39 +103,117 @@ export async function* backfill< } } -async function* getCollectionItems( +function normalizeStrategies( + strategies: readonly BackfillStrategy[] = defaultStrategies, +): readonly BackfillStrategy[] { + if (strategies.includes("context-auto")) return ["context-auto"]; + return Array.from(new Set(strategies)); +} + +async function* getBackfillItems( context: BackfillContext, - collection: BackfillCollection, + object: APObject | Link, + strategies: readonly BackfillStrategy[], options: BackfillOptions, budget: RequestBudget, -): AsyncIterable { - yield* collection.getItems({ - documentLoader: async (url) => { - let object: APObject | null; - try { - object = await loadObject( +): AsyncIterable<{ + readonly object: APObject; + readonly strategy: BackfillStrategy; +}> { + for (const strategy of strategies) { + 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, - new URL(url), + object, options, budget, - true, ); - } catch (error) { - if (error instanceof MaxRequestsExceeded) throw error; - budget.signal?.throwIfAborted(); - return skippedCollectionItemDocument(url); + if (activityObject != null && isContextPostObject(activityObject)) { + yield { object: activityObject, strategy }; + } + } else if (isContextPostObject(object)) { + yield { object, strategy }; } - if (object == null) return skippedCollectionItemDocument(url); - return { - contextUrl: null, - documentUrl: url, - document: await object.toJsonLd(), - }; + } + } +} + +async function* getCollectionItems( + context: BackfillContext, + collection: BackfillCollection, + options: BackfillOptions, + budget: RequestBudget, +): AsyncIterable { + yield* collection.getItems({ + documentLoader: async (url) => { + return await loadCollectionItemDocument(context, url, options, budget); }, crossOrigin: "trust", }); } +async function getCreateActivityObject( + context: BackfillContext, + object: APObject | Link, + options: BackfillOptions, + budget: RequestBudget, +): Promise { + if (!(object instanceof Create)) return null; + try { + return await object.getObject({ + 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; + } +} + +async function loadCollectionItemDocument( + context: BackfillContext, + url: string, + options: BackfillOptions, + budget: RequestBudget, +) { + let object: APObject | null; + try { + object = await loadObject( + context, + new URL(url), + options, + budget, + true, + ); + } catch (error) { + if (error instanceof MaxRequestsExceeded) throw error; + budget.signal?.throwIfAborted(); + return skippedCollectionItemDocument(url); + } + if (object == null) return skippedCollectionItemDocument(url); + return { + contextUrl: null, + documentUrl: url, + document: await object.toJsonLd(), + }; +} + function skippedCollectionItemDocument(url: string) { return { contextUrl: null, diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts index 25555f30e..9ab43ffbf 100644 --- a/packages/backfill/src/types.ts +++ b/packages/backfill/src/types.ts @@ -5,7 +5,10 @@ import type { Object as APObject } from "@fedify/vocab"; * * @since 2.x.0 */ -export type BackfillStrategy = "context-posts"; +export type BackfillStrategy = + | "context-objects" + | "context-activities" + | "context-auto"; /** * Source relation that produced a backfilled object. @@ -56,6 +59,15 @@ export interface BackfillContext { export interface BackfillOptions< TObject extends APObject = APObject, > { + /** + * Backfill strategies to run. + * + * Defaults to `["context-auto"]`. + * + * @since 2.x.0 + */ + readonly strategies?: readonly BackfillStrategy[]; + /** * Maximum number of items to yield. Skipped duplicates do not count. */ From 9776f2d63cfe74dd1421aa2fe44eaacd15348610 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sat, 13 Jun 2026 20:42:13 +0900 Subject: [PATCH 13/37] Clarify backfill strategy docs Document the behavior of each context backfill strategy and make the context-auto precedence explicit in the public options comments. Tighten the README wording so it describes the currently supported Create activity handling instead of implying support for every Activity type. Assisted-by: Codex:gpt-5 --- packages/backfill/README.md | 3 ++- packages/backfill/src/types.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/backfill/README.md b/packages/backfill/README.md index b7f905a34..70576fd39 100644 --- a/packages/backfill/README.md +++ b/packages/backfill/README.md @@ -64,7 +64,8 @@ collection, it is skipped by ID. 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 an Activity, `backfill()` extracts the activity's object instead. +recognized as a supported `Create` activity, `backfill()` extracts the +activity's object instead. To read only FEP-f228 activity collections, enable the `context-activities` strategy: diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts index 9ab43ffbf..ae6d264ba 100644 --- a/packages/backfill/src/types.ts +++ b/packages/backfill/src/types.ts @@ -3,6 +3,14 @@ import type { Object as APObject } from "@fedify/vocab"; /** * Backfill traversal strategy used to discover the returned object. * + * - `"context-objects"` yields post-like objects directly from the context + * collection. + * - `"context-activities"` yields objects extracted from supported `Create` + * 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. + * * @since 2.x.0 */ export type BackfillStrategy = @@ -63,6 +71,7 @@ export interface BackfillOptions< * Backfill strategies to run. * * Defaults to `["context-auto"]`. + * If `"context-auto"` is included, it absorbs all other strategies. * * @since 2.x.0 */ From 1e7a85372bb145c341a865b6e96dcda08a59762b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 15 Jun 2026 14:28:22 +0900 Subject: [PATCH 14/37] 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 15/37] 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 16/37] 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 17/37] 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 18/37] 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 19/37] 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 20/37] 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 21/37] 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 22/37] 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 23/37] 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 24/37] 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 25/37] 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 26/37] 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 27/37] 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 28/37] 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 29/37] 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 30/37] 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; From 90e0abe1c2b2f0eec646ae544b5fec261dd52c7a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 18 Jun 2026 22:30:00 +0900 Subject: [PATCH 31/37] Preserve reply-tree traversal semantics Keep descendant depths relative to the original seed when walking from discovered ancestors, so maxDepth applies consistently across the tree. Allow replies collections to be retried after transient loader failures by marking their IDs visited only after a successful load. Add regression tests for both edge cases. Assisted-by: Codex:gpt-5.4 --- packages/backfill/src/backfill.test.ts | 84 ++++++++++++++++++++++++++ packages/backfill/src/backfill.ts | 28 +++++---- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/packages/backfill/src/backfill.test.ts b/packages/backfill/src/backfill.test.ts index 9a38c6278..3875eea7e 100644 --- a/packages/backfill/src/backfill.test.ts +++ b/packages/backfill/src/backfill.test.ts @@ -635,8 +635,44 @@ describe("backfill", () => { strictEqual(items.length, 2); strictEqual(items[0].object, parent); strictEqual(items[0].origin, "in-reply-to"); + strictEqual(items[0].depth, 1); strictEqual(items[1].object, sibling); strictEqual(items[1].origin, "replies"); + strictEqual(items[1].depth, 2); + }); + + test("reply tree maxDepth applies from seed through ancestors", 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"], + maxDepth: 1, + }); + + strictEqual(items.length, 1); + strictEqual(items[0].object, parent); + strictEqual(items[0].depth, 1); }); test("reply tree dereferences replies collection URL", async () => { @@ -789,6 +825,54 @@ describe("backfill", () => { strictEqual(items[0].object.id?.href, reply.id?.href); }); + test("reply tree retries a replies collection after load failure", async () => { + const repliesId = new URL("https://example.com/replies/shared"); + const grandchild = new Note({ + id: new URL("https://example.com/notes/4"), + content: "grandchild", + }); + const first = new Note({ + id: new URL("https://example.com/notes/2"), + replies: repliesId, + }); + const second = new Note({ + id: new URL("https://example.com/notes/3"), + replies: repliesId, + }); + 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: [first, second], + }), + }); + let requests = 0; + const context: BackfillContext = { + documentLoader: (iri) => { + strictEqual(iri.href, repliesId.href); + requests++; + if (requests === 1) throw new Error("temporary failure"); + return Promise.resolve( + new Collection({ + id: repliesId, + items: [grandchild], + }), + ); + }, + }; + + const items = await collect(context, note, { + strategies: ["reply-tree"], + }); + + strictEqual(requests, 2); + deepStrictEqual( + items.map((item) => item.object.id?.href), + [first.id?.href, second.id?.href, grandchild.id?.href], + ); + strictEqual(items[2].depth, 2); + }); + 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"); diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index db56361f2..02cb32319 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -276,7 +276,10 @@ async function* getReplyTreeItems( const visitedCollections = new WeakSet(); if (note.id != null) visitedObjectIds.add(note.id.href); visitedObjects.add(note); - const ancestors: APObject[] = []; + const ancestors: Array<{ + readonly object: APObject; + readonly depth: number; + }> = []; for await ( const item of getReplyAncestors(context, note, options, budget, { depth: 1, @@ -286,12 +289,12 @@ async function* getReplyTreeItems( visitedCollections, }) ) { - ancestors.push(item.object); + ancestors.push({ object: item.object, depth: item.depth }); yield item; } - for (const object of ancestors.toReversed()) { - yield* getReplyDescendants(context, object, options, budget, { - depth: 1, + for (const ancestor of ancestors.toReversed()) { + yield* getReplyDescendants(context, ancestor.object, options, budget, { + depth: ancestor.depth + 1, visitedObjectIds, visitedObjects, visitedCollectionIds, @@ -355,18 +358,17 @@ async function* getReplyDescendants( }> { if (traversal.depth > (options.maxDepth ?? DEFAULT_MAX_DEPTH)) return; const repliesId = object.repliesId; - let repliesIdVisited = false; - if (repliesId != null && !visitReplyTreeCollectionId(repliesId, traversal)) { + if ( + repliesId != null && + traversal.visitedCollectionIds.has(repliesId.href) + ) { 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; - } + const unvisited = visitReplyTreeCollection(replies, traversal); + if (repliesId != null) traversal.visitedCollectionIds.add(repliesId.href); + if (!unvisited) return; for await ( const reply of getCollectionItems( context, From fd6f64507c4069e288f73b3530d3b49f835a822d Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 21 Jun 2026 23:09:23 +0900 Subject: [PATCH 32/37] Document FEP-f228 support List Backfilling conversations among the FEPs supported by Fedify so FEDERATION.md reflects the new backfill package capabilities. Assisted-by: Codex:gpt-5.4 --- FEDERATION.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/FEDERATION.md b/FEDERATION.md index d0ac97e14..71c7a68bd 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -25,6 +25,7 @@ Supported FEPs -------------- - [FEP-67ff][]: FEDERATION.md + - [FEP-f228][]: Backfilling conversations - [FEP-8fcf][]: Followers collection synchronization across servers - [FEP-9091][]: Export Actor Service Endpoint - [FEP-f1d5][]: NodeInfo in Fediverse Software @@ -40,6 +41,7 @@ Supported FEPs - [FEP-ae0c][]: Fediverse Relay Protocols: Mastodon and LitePub [FEP-67ff]: https://w3id.org/fep/67ff +[FEP-f228]: https://w3id.org/fep/f228 [FEP-8fcf]: https://w3id.org/fep/8fcf [FEP-9091]: https://w3id.org/fep/9091 [FEP-f1d5]: https://w3id.org/fep/f1d5 From e32f5bbb42a15970cb7a135420708028ef150914 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 23 Jun 2026 19:13:43 +0900 Subject: [PATCH 33/37] Add documentation Add changes and package descriptions. Fix version to 2.3.0. Assisted-by: Codex:gpt-5.5 --- CHANGES.md | 13 ++++++++++ CONTRIBUTING.md | 2 ++ packages/backfill/README.md | 43 ++++++++++++++++++++++++++++++- packages/backfill/src/backfill.ts | 4 +-- packages/backfill/src/types.ts | 17 ++++++------ 5 files changed, 67 insertions(+), 12 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 9398d5286..e7c2b6fab 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -221,6 +221,19 @@ To be released. [#771]: https://github.com/fedify-dev/fedify/pull/771 [#772]: https://github.com/fedify-dev/fedify/pull/772 +### @fedify/backfill + + - Added *@fedify/backfill* for reconstructing ActivityPub conversations. + It supports FEP-f228 context collections containing post-like objects or + `Create` activities, optional reply-tree traversal, ordered hybrid + strategies, shared safety budgets, deduplication, and traversal-local + document caching. [[#275], [#779], [#801], [#807] by Jiwon Kwon] + +[#275]: https://github.com/fedify-dev/fedify/issues/275 +[#779]: https://github.com/fedify-dev/fedify/pull/779 +[#801]: https://github.com/fedify-dev/fedify/pull/801 +[#807]: https://github.com/fedify-dev/fedify/pull/807 + ### @fedify/fixture - Added `createTestMeterProvider()` and `TestMetricRecorder` helpers for diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c421d48f..21327fa4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -407,6 +407,8 @@ The repository is organized as a monorepo with the following packages: creating new Fedify projects. Wraps @fedify/init. - *packages/amqp/*: AMQP/RabbitMQ driver (@fedify/amqp) for Fedify. - *packages/astro/*: Astro integration (@fedify/astro) for Fedify. + - *packages/backfill/*: ActivityPub conversation backfill support + (@fedify/backfill) for Fedify. - *packages/cfworkers/*: Cloudflare Workers integration (@fedify/cfworkers) for Fedify. - *packages/debugger/*: Embedded ActivityPub debug dashboard diff --git a/packages/backfill/README.md b/packages/backfill/README.md index cd1e5f33b..a9e4e06c5 100644 --- a/packages/backfill/README.md +++ b/packages/backfill/README.md @@ -11,7 +11,7 @@ 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 collection, following the direct [FEP-f228] path where the context dereferences to a `Collection`, `OrderedCollection`, `CollectionPage`, or `OrderedCollectionPage`. It can also use an opt-in reply-tree strategy to walk `inReplyTo` ancestors and `replies` descendants when context collections @@ -24,6 +24,7 @@ are unavailable or incomplete. [@fedify@hollo.social badge]: https://fedi-badge.deno.dev/@fedify@hollo.social/followers.svg [@fedify@hollo.social]: https://hollo.social/@fedify [Fedify]: https://fedify.dev/ +[FEP-f228]: https://w3id.org/fep/f228 Installation @@ -73,6 +74,19 @@ collection items are treated as backfillable objects by default. If an item is recognized as a supported `Create` activity, `backfill()` extracts the activity's object instead. +To accept only post-like objects directly contained in the context collection, +use the `context-objects` strategy: + +~~~~ typescript +for await ( + const item of backfill({ documentLoader }, note, { + strategies: ["context-objects"], + }) +) { + console.log(item.object); +} +~~~~ + To read only FEP-f228 activity collections, enable the `context-activities` strategy: @@ -109,3 +123,30 @@ 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. + + +Traversal controls +------------------ + +All configured strategies share the same traversal controls: + + - `maxItems` limits the number of yielded objects. Skipped duplicates do + not count. + - `maxRequests` limits calls to `documentLoader`. Embedded objects and + collections do not count. + - `maxDepth` limits reply-tree traversal and defaults to 10. It does not + limit context collection items. + - `interval` adds a delay between loader requests. Its callback receives + the zero-based request index. + - `signal` cancels traversal and is forwarded to `documentLoader`. + +An `interval` string requires the global `Temporal` API or a polyfill. +`Temporal.DurationLike` objects work without the global API. + +If the seed has no context, or its context resolves to a non-collection, +context strategies yield nothing. Loader failures are skipped unless +traversal is aborted. + +Dereferenced documents are cached in memory for one `backfill()` traversal. +Applications that need persistent or shared caching can provide it through +the `documentLoader`. diff --git a/packages/backfill/src/backfill.ts b/packages/backfill/src/backfill.ts index 02cb32319..a836f4c4a 100644 --- a/packages/backfill/src/backfill.ts +++ b/packages/backfill/src/backfill.ts @@ -26,7 +26,7 @@ const DEFAULT_MAX_DEPTH = 10; /** * Thrown when backfill traversal exceeds the configured request budget. * - * @since 2.x.0 + * @since 2.3.0 */ export class MaxRequestsExceeded extends Error {} @@ -57,7 +57,7 @@ type ReplyTreeTraversal = { * The seed object is not yielded by default, but its ID is treated as already * seen so it will not be yielded again if the collection contains it. * - * @since 2.x.0 + * @since 2.3.0 */ export async function* backfill< TObject extends APObject = APObject, diff --git a/packages/backfill/src/types.ts b/packages/backfill/src/types.ts index c7d15f80d..58090e698 100644 --- a/packages/backfill/src/types.ts +++ b/packages/backfill/src/types.ts @@ -13,7 +13,7 @@ import type { Object as APObject } from "@fedify/vocab"; * - `"reply-tree"` walks the reply graph through `inReplyTo` ancestors and * `replies` descendants, yielding discovered post-like objects. * - * @since 2.x.0 + * @since 2.3.0 */ export type BackfillStrategy = | "context-objects" @@ -24,10 +24,9 @@ export type BackfillStrategy = /** * Source relation that produced a backfilled object. * - * @since 2.x.0 + * @since 2.3.0 */ export type BackfillOrigin = - | "context" | "collection" | "in-reply-to" | "replies"; @@ -35,7 +34,7 @@ export type BackfillOrigin = /** * Options passed to {@link BackfillDocumentLoader}. * - * @since 2.x.0 + * @since 2.3.0 */ export interface BackfillDocumentLoaderOptions { /** @@ -47,7 +46,7 @@ export interface BackfillDocumentLoaderOptions { /** * Dereferences an ActivityPub object or collection IRI. * - * @since 2.x.0 + * @since 2.3.0 */ export type BackfillDocumentLoader = ( iri: URL, @@ -57,7 +56,7 @@ export type BackfillDocumentLoader = ( /** * Dependencies used by backfill traversal. * - * @since 2.x.0 + * @since 2.3.0 */ export interface BackfillContext { /** @@ -70,7 +69,7 @@ export interface BackfillContext { /** * Controls backfill traversal. * - * @since 2.x.0 + * @since 2.3.0 */ export interface BackfillOptions< TObject extends APObject = APObject, @@ -86,7 +85,7 @@ export interface BackfillOptions< * If `"context-auto"` is included, it absorbs other context collection * strategies. * - * @since 2.x.0 + * @since 2.3.0 */ readonly strategies?: readonly BackfillStrategy[]; @@ -134,7 +133,7 @@ export interface BackfillOptions< /** * A single object discovered by backfill traversal. * - * @since 2.x.0 + * @since 2.3.0 */ export interface BackfillItem< TObject extends APObject = APObject, From c6ef99de518cf1d2e2e82ca63e6cb52e39a39a39 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 23 Jun 2026 23:41:45 +0900 Subject: [PATCH 34/37] Add backfill manual page Publish the @fedify/backfill guide on the documentation website with installation commands, strategy examples, traversal controls, and cache and failure behavior. Add the package to the docs workspace so Twoslash can validate the examples. Assisted-by: Codex:gpt-5.5 --- docs/.vitepress/config.mts | 1 + docs/manual/backfill.md | 203 +++++++++++++++++++++++++++++++++++++ docs/package.json | 1 + pnpm-lock.yaml | 3 + 4 files changed, 208 insertions(+) create mode 100644 docs/manual/backfill.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5c2a39a32..d58897b1f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -137,6 +137,7 @@ const MANUAL = { { text: "Outbox listeners", link: "/manual/outbox.md" }, { text: "Sending activities", link: "/manual/send.md" }, { text: "Collections", link: "/manual/collections.md" }, + { text: "Conversation backfill", link: "/manual/backfill.md" }, { text: "Object dispatcher", link: "/manual/object.md" }, { text: "Access control", link: "/manual/access-control.md" }, { text: "WebFinger", link: "/manual/webfinger.md" }, diff --git a/docs/manual/backfill.md b/docs/manual/backfill.md new file mode 100644 index 000000000..74f52280f --- /dev/null +++ b/docs/manual/backfill.md @@ -0,0 +1,203 @@ +--- +description: >- + Reconstruct ActivityPub conversations from FEP-f228 context collections or + reply relationships using the @fedify/backfill package. +--- + +Conversation backfill +===================== + +*This API is available since Fedify 2.3.0.* + +Fedify provides the *@fedify/backfill* package for reconstructing ActivityPub +conversations that may be incomplete on the local server. It can retrieve +post-like objects from [FEP-f228] context collections and optionally crawl +`inReplyTo` ancestors and `replies` descendants. + +[FEP-f228]: https://w3id.org/fep/f228 + + +Installation +------------ + +::: code-group + +~~~~ sh [Deno] +deno add jsr:@fedify/backfill +~~~~ + +~~~~ sh [npm] +npm add @fedify/backfill +~~~~ + +~~~~ sh [pnpm] +pnpm add @fedify/backfill +~~~~ + +~~~~ sh [Yarn] +yarn add @fedify/backfill +~~~~ + +~~~~ sh [Bun] +bun add @fedify/backfill +~~~~ + +::: + + +Backfilling a conversation +-------------------------- + +The `backfill()` function accepts a backfill context, a seed object, and +traversal options. The context supplies a `documentLoader` for dereferencing +context collections, collection items, reply targets, and replies collections: + +~~~~ typescript twoslash +import { backfill, type BackfillDocumentLoader } from "@fedify/backfill"; +import { lookupObject, Note } from "@fedify/vocab"; + +declare const note: Note; +// ---cut-before--- +const documentLoader: BackfillDocumentLoader = (iri, options) => + lookupObject(iri, { signal: options?.signal }); + +for await ( + const item of backfill({ documentLoader }, note, { + maxItems: 20, + maxRequests: 50, + }) +) { + console.log(item.id?.href); +} +~~~~ + +The seed object itself is not yielded. If the same object appears in a +discovered collection, it is skipped by ID. + +By default, `backfill()` uses the `"context-auto"` strategy. It expects the +seed's `context` to dereference to a `Collection`, `OrderedCollection`, +`CollectionPage`, or `OrderedCollectionPage`. Ordinary post-like items are +yielded directly, while supported `Create` activities are unwrapped and their +objects are yielded. + +If the seed has no context, or its context resolves to a non-collection, +context strategies yield nothing. + + +Strategies +---------- + +Strategies run in the configured order. They share request and item budgets, +abort state, document caching, and object ID deduplication. If multiple +strategies discover the same object, the first one keeps its `BackfillItem` +metadata. + +`"context-auto"` +: Handles both direct post-like objects and supported `Create` activities + from a context collection. This is the default strategy. + +`"context-objects"` +: Accepts only post-like objects contained directly in a context collection: + + ~~~~ typescript twoslash + import { backfill, type BackfillContext } from "@fedify/backfill"; + import { Note } from "@fedify/vocab"; + + declare const context: BackfillContext; + declare const note: Note; + // ---cut-before--- + for await ( + const item of backfill(context, note, { + strategies: ["context-objects"], + }) + ) { + console.log(item.object); + } + ~~~~ + +`"context-activities"` +: Accepts supported activities from a context collection. It currently + supports `Create` and yields the activity's object rather than the activity + itself: + + ~~~~ typescript twoslash + import { backfill, type BackfillContext } from "@fedify/backfill"; + import { Note } from "@fedify/vocab"; + + declare const context: BackfillContext; + declare const note: Note; + // ---cut-before--- + for await ( + const item of backfill(context, note, { + strategies: ["context-activities"], + }) + ) { + console.log(item.object); + } + ~~~~ + +`"reply-tree"` +: Walks `inReplyTo` ancestors and `replies` descendants. It yields + post-like objects only and does not unwrap Activity objects. This strategy + is opt-in because it can require substantially more network requests than + a context collection. + +For hybrid coverage, run the FEP-f228 path first and use reply-tree traversal +after it: + +~~~~ typescript twoslash +import { backfill, type BackfillContext } from "@fedify/backfill"; +import { Note } from "@fedify/vocab"; + +declare const context: BackfillContext; +declare const note: Note; +// ---cut-before--- +for await ( + const item of backfill(context, note, { + strategies: ["context-auto", "reply-tree"], + maxDepth: 4, + }) +) { + console.log(item.origin, item.depth, item.object); +} +~~~~ + + +Traversal controls +------------------ + +`maxItems` +: Limits the number of yielded objects. Skipped duplicates do not count. + +`maxRequests` +: Limits calls to `documentLoader`. Embedded objects and collections do not + count as requests. + +`maxDepth` +: Limits reply-tree traversal and defaults to 10. Immediate parents and + direct replies have depth 1; their next-level parents or replies have depth + 2, and so on. Context collection items have depth 0 and are not limited by + this option. + +`interval` +: Adds a delay between `documentLoader` requests. A callback receives the + zero-based request index. String durations require the global `Temporal` + API or a polyfill; `Temporal.DurationLike` objects work without the global + API. + +`signal` +: Cancels traversal before requests and yields. The signal is also passed to + `documentLoader`. + + +Caching and failures +-------------------- + +Dereferenced documents are cached in memory for one `backfill()` traversal. +Applications that need persistent or shared caching can implement it in the +provided `documentLoader`. + +Failed external dereferences are skipped so other conversation items can still +be discovered. Failed loads are not retained in the traversal cache, allowing +the same IRI to be retried if another traversal path reaches it. Aborting the +provided signal stops traversal instead of skipping the request. diff --git a/docs/package.json b/docs/package.json index 9d59d59a7..6d6548d20 100644 --- a/docs/package.json +++ b/docs/package.json @@ -5,6 +5,7 @@ "@deno/kv": "^0.8.4", "@fedify/amqp": "workspace:^", "@fedify/astro": "workspace:^", + "@fedify/backfill": "workspace:^", "@fedify/cfworkers": "workspace:^", "@fedify/debugger": "workspace:^", "@fedify/express": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df3689951..b694adbe4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ importers: '@fedify/astro': specifier: workspace:^ version: link:../packages/astro + '@fedify/backfill': + specifier: workspace:^ + version: link:../packages/backfill '@fedify/cfworkers': specifier: workspace:^ version: link:../packages/cfworkers From 4e9348ad6639bef6a4a518f81ba7a5a3f2344663 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 24 Jun 2026 13:48:34 +0900 Subject: [PATCH 35/37] Add pull request 816 to CHANGES.md --- CHANGES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index e7c2b6fab..9dd38cb0c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -227,12 +227,13 @@ To be released. It supports FEP-f228 context collections containing post-like objects or `Create` activities, optional reply-tree traversal, ordered hybrid strategies, shared safety budgets, deduplication, and traversal-local - document caching. [[#275], [#779], [#801], [#807] by Jiwon Kwon] + document caching. [[#275], [#779], [#801], [#807], [#816] by Jiwon Kwon] [#275]: https://github.com/fedify-dev/fedify/issues/275 [#779]: https://github.com/fedify-dev/fedify/pull/779 [#801]: https://github.com/fedify-dev/fedify/pull/801 [#807]: https://github.com/fedify-dev/fedify/pull/807 +[#816]: https://github.com/fedify-dev/fedify/pull/816 ### @fedify/fixture From d1f446d8b329cd97dd6bde41f1999cbfcb3ac2b1 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 24 Jun 2026 20:39:33 +0900 Subject: [PATCH 36/37] Fix pnpm-lock --- pnpm-lock.yaml | 68 +++++++++++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b694adbe4..572a82a59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -852,7 +852,7 @@ importers: version: 0.10.8 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -868,7 +868,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -881,7 +881,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -900,7 +900,7 @@ importers: version: 0.8.71(@cloudflare/workers-types@4.20260511.1)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)) tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1036,7 +1036,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1061,7 +1061,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1095,7 +1095,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1114,7 +1114,7 @@ importers: version: 1.2.19(@types/react@19.1.8) tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1136,7 +1136,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1158,7 +1158,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1240,7 +1240,7 @@ importers: version: 4.20250617.4 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -1274,7 +1274,7 @@ importers: version: 0.5.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1293,7 +1293,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1309,7 +1309,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1349,7 +1349,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1371,7 +1371,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1402,7 +1402,7 @@ importers: version: 9.32.0(jiti@2.6.1) tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1433,7 +1433,7 @@ importers: version: link:../testing tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1458,7 +1458,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1474,7 +1474,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1505,7 +1505,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1536,7 +1536,7 @@ importers: version: '@jsr/std__async@1.0.13' tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1570,7 +1570,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1595,7 +1595,7 @@ importers: version: link:../vocab-runtime tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1614,7 +1614,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1642,7 +1642,7 @@ importers: version: '@jsr/std__async@1.0.13' tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1658,7 +1658,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1686,7 +1686,7 @@ importers: version: '@jsr/std__async@1.0.13' tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1701,7 +1701,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1756,7 +1756,7 @@ importers: version: 12.6.0 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1796,7 +1796,7 @@ importers: version: 12.6.0 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1821,7 +1821,7 @@ importers: version: 22.19.1 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1852,7 +1852,7 @@ importers: version: 12.6.0 tsdown: specifier: 'catalog:' - version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -25510,7 +25510,7 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34): + tsdown@0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)): dependencies: ansis: 4.3.0 cac: 7.0.0 From 3d4cc9089e0950dc1c7672c45727670ca78cf593 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 24 Jun 2026 23:56:43 +0900 Subject: [PATCH 37/37] Format CHANGES.mdd --- CHANGES.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index b720d09bc..41d455989 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -356,7 +356,6 @@ To be released. [#275]: https://github.com/fedify-dev/fedify/issues/275 [#779]: https://github.com/fedify-dev/fedify/pull/779 -[#801]: https://github.com/fedify-dev/fedify/pull/801 [#807]: https://github.com/fedify-dev/fedify/pull/807 [#816]: https://github.com/fedify-dev/fedify/pull/816