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/packages/backfill/README.md b/packages/backfill/README.md new file mode 100644 index 000000000..3d38a762c --- /dev/null +++ b/packages/backfill/README.md @@ -0,0 +1,79 @@ + + +@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 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 +[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 +~~~~ + +::: + + +Usage +----- + +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. 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..1e80dd9d8 --- /dev/null +++ b/packages/backfill/package.json @@ -0,0 +1,68 @@ +{ + "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": { + "@fedify/vocab": "workspace:*" + }, + "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/backfill.test.ts b/packages/backfill/src/backfill.test.ts new file mode 100644 index 000000000..9c827ee92 --- /dev/null +++ b/packages/backfill/src/backfill.test.ts @@ -0,0 +1,377 @@ +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"; + +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("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({ + 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 new file mode 100644 index 000000000..b0bf00cea --- /dev/null +++ b/packages/backfill/src/backfill.ts @@ -0,0 +1,220 @@ +import { + Activity, + Collection, + CollectionPage, + type Link, + Object as APObject, + OrderedCollection, + OrderedCollectionPage, +} from "@fedify/vocab"; + +import type { + BackfillContext, + BackfillItem, + BackfillOptions, +} from "./types.ts"; + +class MaxRequestsExceeded extends Error {} + +interface RequestBudget { + readonly signal?: AbortSignal; + requestCount: number; +} + +/** + * 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 APObject = APObject, +>( + context: BackfillContext, + note: TObject, + options: BackfillOptions = {}, +): AsyncGenerator, void, void> { + 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) => { + 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(), + }; + }, + crossOrigin: "trust", + }); +} + +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, + 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) => { + 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); + }; + budget.signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +function durationToMilliseconds( + duration: Temporal.DurationLike | string, +): 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" }); + } + + 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; +} + +function isContextPostObject( + object: APObject | Link, +): object is APObject { + return object instanceof APObject && + !(object instanceof Activity) && + !isCollection(object); +} diff --git a/packages/backfill/src/mod.ts b/packages/backfill/src/mod.ts new file mode 100644 index 000000000..6ceb69ac2 --- /dev/null +++ b/packages/backfill/src/mod.ts @@ -0,0 +1,18 @@ +/** + * ActivityPub backfill support for Fedify. + * + * This package provides async generator APIs for collecting historical + * ActivityPub objects related to a seed object. + * + * @module + */ +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..b6db32be8 --- /dev/null +++ b/packages/backfill/src/types.ts @@ -0,0 +1,112 @@ +import type { Object as APObject } 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 APObject = APObject, +> { + /** + * 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 + | string + | ((iteration: number) => Temporal.DurationLike | string); + + /** + * Cancels traversal before requests and before yields. + */ + signal?: AbortSignal; +} + +/** + * A single object discovered by backfill traversal. + */ +export interface BackfillItem< + TObject extends APObject = APObject, +> { + /** + * 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; +} diff --git a/packages/backfill/tsdown.config.ts b/packages/backfill/tsdown.config.ts new file mode 100644 index 000000000..bd0f4b7a0 --- /dev/null +++ b/packages/backfill/tsdown.config.ts @@ -0,0 +1,32 @@ +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", + }; + }, + deps: { neverBundle: ["@fedify/vocab"] }, + }), + defineConfig({ + entry: (await Array.fromAsync(glob(`src/**/*.test.ts`))) + .map((f) => f.replaceAll(sep, "/")), + format: ["esm", "cjs"], + platform: "node", + outExtensions({ format }) { + return { + js: format === "cjs" ? ".cjs" : ".js", + dts: format === "cjs" ? ".d.cts" : ".d.ts", + }; + }, + deps: { neverBundle: [/^node:/, "@fedify/vocab"] }, + }), +]; 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..df3689951 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -870,6 +870,19 @@ importers: specifier: 'catalog:' version: 6.0.3 + packages/backfill: + dependencies: + '@fedify/vocab': + specifier: workspace:* + version: link:../vocab + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.22.0(tsx@4.21.0)(typescript@6.0.3)(unrun@0.2.34) + typescript: + specifier: 'catalog:' + version: 6.0.3 + packages/cfworkers: dependencies: '@cloudflare/workers-types': 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