From 7c307bf8d7285f04f552aac9e43f175b6c6c749f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 9 Oct 2026 08:33:13 +0900 Subject: [PATCH 1/4] Backfill remote conversations from context Use Fedify 2.4.2 and @fedify/backfill to ingest seed-owned FEP-f228 context collections before falling back to durable replies jobs. Coalesce collection aliases, preserve restart and lease fencing, and share request budgets and host pacing with post persistence. Preserve canonical posts, atomically save complete items, and reconcile available parents without fetching missing ancestors. Document the new request cap and cover pagination, safety, recovery, and API filters. Prepare remote dependencies before short fenced item commits while retaining ordinary inbox quote notifications on media failures. OpenCode's DeepSeek Flash review found no actionable defects. Fixes https://github.com/fedify-dev/hollo/issues/661 Assisted-by: Codex:gpt-6.1-sol Assisted-by: OpenCode:deepseek-flash Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-opus-5-5 --- .env.sample | 2 + CHANGES.md | 13 +- compose-fs.yaml | 1 + compose.yaml | 1 + docs/src/content/docs/install/env.mdx | 30 +- docs/src/content/docs/ja/install/env.mdx | 33 +- docs/src/content/docs/ko/install/env.mdx | 29 +- docs/src/content/docs/zh-cn/install/env.mdx | 27 +- docs/src/content/docs/zh-tw/install/env.mdx | 27 +- .../migration.sql | 21 + .../snapshot.json | 8361 +++++++++++++++++ package.json | 18 +- pnpm-lock.yaml | 151 +- src/federation/account.ts | 20 +- src/federation/post.ts | 369 +- src/federation/replies-context.ts | 160 + src/federation/replies-loader.ts | 311 + src/federation/replies-state.ts | 46 + src/federation/replies-tasks.ts | 28 +- src/federation/replies-worker.test.ts | 1273 ++- src/federation/replies-worker.ts | 726 +- src/federation/replies.ts | 306 +- src/schema.ts | 41 +- 23 files changed, 11279 insertions(+), 715 deletions(-) create mode 100644 drizzle/20261008183749_context_backfill/migration.sql create mode 100644 drizzle/20261008183749_context_backfill/snapshot.json create mode 100644 src/federation/replies-context.ts create mode 100644 src/federation/replies-loader.ts create mode 100644 src/federation/replies-state.ts diff --git a/.env.sample b/.env.sample index e477fece..49216426 100644 --- a/.env.sample +++ b/.env.sample @@ -19,6 +19,8 @@ PORT=3000 ALLOW_PRIVATE_ADDRESS=false REMOTE_ACTOR_FETCH_POSTS=10 +# Document-loader call budget per remote context/replies backfill attempt: +REMOTE_REPLIES_SCRAPE_MAX_REQUESTS=200 # File uploads and media storage: DRIVE_DISK= diff --git a/CHANGES.md b/CHANGES.md index f7a384d5..2c457d1f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,7 +6,7 @@ Version 0.10.0 To be released. - - Upgraded Fedify to 2.4.1. + - Upgraded Fedify to 2.4.2. - Quotes awaiting [FEP-044f] approval now expose `quoteUrl` for compatibility with older software when the original public or unlisted post explicitly @@ -73,6 +73,14 @@ To be released. after expiry. Delayed poll schedules no longer allocate worker timers. [[#653], [#663]] + - Remote conversation backfill now reads [FEP-f228] `context` collections + through *@fedify/backfill* before falling back to `replies`. Collection + aliases share durable jobs, and each seed's replies wait for the context + result. Backfill preserves existing posts, restores available parent + links, and shares a per-attempt document-loader budget across traversal + and persistence. Set `REMOTE_REPLIES_SCRAPE_MAX_REQUESTS` to tune that + budget (default: `200`). [[#661], [#664]] + - Replaced the undocumented `SEONBI_URL` integration with the embedded [Gukhanmun] Node-API binding. Set `GUKHANMUN` to a comma-separated list of locale patterns, such as `ko,ko-*`, to add Hangul readings to Hanja in @@ -138,6 +146,7 @@ To be released. 14 adds post-quantum ML-DSA passkey support on runtimes that provide the algorithms. [[GHSA-2g3p-m8c9-hhwh], [GHSA-j3h4-m3m2-7p7j]] +[FEP-f228]: https://w3id.org/fep/f228 [FEP-044f]: https://w3id.org/fep/044f [FEP-c0e0]: https://w3id.org/fep/c0e0 [Gukhanmun]: https://gukhanmun.org/ @@ -163,7 +172,9 @@ To be released. [#650]: https://github.com/fedify-dev/hollo/pull/650 [#653]: https://github.com/fedify-dev/hollo/issues/653 [#660]: https://github.com/fedify-dev/hollo/pull/660 +[#661]: https://github.com/fedify-dev/hollo/issues/661 [#663]: https://github.com/fedify-dev/hollo/pull/663 +[#664]: https://github.com/fedify-dev/hollo/pull/664 Version 0.9.22 diff --git a/compose-fs.yaml b/compose-fs.yaml index 88f7435c..7b7ba862 100644 --- a/compose-fs.yaml +++ b/compose-fs.yaml @@ -8,6 +8,7 @@ services: SECRET_KEY: "${SECRET_KEY}" LOG_LEVEL: "${LOG_LEVEL}" BEHIND_PROXY: "${BEHIND_PROXY}" + # REMOTE_REPLIES_SCRAPE_MAX_REQUESTS: "200" # GUKHANMUN: "ko,ko-*" DRIVE_DISK: fs STORAGE_URL_BASE: http://localhost:3000/assets/ diff --git a/compose.yaml b/compose.yaml index 185a7670..a3b6a9de 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,6 +12,7 @@ services: SECRET_KEY: "${SECRET_KEY}" LOG_LEVEL: "${LOG_LEVEL}" BEHIND_PROXY: "${BEHIND_PROXY}" + # REMOTE_REPLIES_SCRAPE_MAX_REQUESTS: "200" # GUKHANMUN: "ko,ko-*" DRIVE_DISK: s3 STORAGE_URL_BASE: http://localhost:9000/hollo/ diff --git a/docs/src/content/docs/install/env.mdx b/docs/src/content/docs/install/env.mdx index 433d293f..c8d340b0 100644 --- a/docs/src/content/docs/install/env.mdx +++ b/docs/src/content/docs/install/env.mdx @@ -158,18 +158,35 @@ encountered first time. ### `REMOTE_REPLIES_SCRAPE_DEPTH` -The number of remote reply levels to scrape in background worker jobs. -Set this to `0` to disable remote replies scraping. +The number of remote reply levels to scrape in background jobs. Hollo first +reads a remote post’s FEP-f228 `context` collection, when present, and uses +`replies` collections as a fallback. Set this to `0` to disable both. +Missing ancestors outside the context collection are not fetched. Posts +with missing parents are stored, but remain disconnected from those parents +in the status-context reply tree. `2` by default. ### `REMOTE_REPLIES_SCRAPE_MAX_ITEMS` -The maximum number of reply items to persist from a single remote replies -scraping job. +The maximum number of items yielded by one context backfill attempt, +including already stored posts. For a `replies` job, this limits persisted +reply items. Reaching the cap leaves the context partial and enables the +seed posts’ `replies` fallback. `100` by default. +### `REMOTE_REPLIES_SCRAPE_MAX_REQUESTS` + +The maximum number of document-loader calls in one backfill attempt, shared +by collection traversal and post/actor persistence. Cached documents and +built-in JSON-LD contexts do not count; custom JSON-LD contexts do. +This does not limit redirects or signature retries inside the loader, +WebFinger, NodeInfo, media downloads, or preview-card requests. Reaching the +cap leaves the context partial and enables the `replies` fallback. + +`200` by default. + ### `REMOTE_EMOJI_REACTIONS_FETCH_MAX_ITEMS` The maximum number of emoji reaction items to import from a remote @@ -193,8 +210,9 @@ The fallback delay before retrying a remote replies scraping job after an HTTP ### `REMOTE_REPLIES_SCRAPE_COOLDOWN_SECONDS` -The time window during which completed remote replies scraping jobs suppress -duplicate jobs for the same replies collection. +The time window during which completed remote scraping jobs suppress +duplicates for the same collection. Failed context jobs also observe this +cooldown; their seeds can still fall back to `replies` collections. `300` by default. diff --git a/docs/src/content/docs/ja/install/env.mdx b/docs/src/content/docs/ja/install/env.mdx index f32585cb..b3039cf2 100644 --- a/docs/src/content/docs/ja/install/env.mdx +++ b/docs/src/content/docs/ja/install/env.mdx @@ -155,16 +155,32 @@ Gukhanmunの読み込みまたは変換に失敗すると、投稿リクエス ### `REMOTE_REPLIES_SCRAPE_DEPTH` -バックグラウンドワーカージョブで取得するリモート返信の階層数。 -`0`に設定すると、リモート返信の取得を無効にします。 +バックグラウンドで取得するリモート返信の階層数です。投稿に FEP-f228 の +`context` コレクションがあれば先に読み、必要に応じて `replies` に +フォールバックします。`0` にすると両方を無効にします。コンテキスト +コレクションに含まれない祖先の投稿は取得しません。親がない投稿は保存しますが、 +ステータスコンテキストの返信ツリーではその親に接続されません。 -デフォルトは`2`です。 +デフォルトは `2` です。 ### `REMOTE_REPLIES_SCRAPE_MAX_ITEMS` -1つのリモート返信取得ジョブから保存する返信アイテムの最大数。 +コンテキスト取得の各試行で走査する項目数の上限です。保存済みの投稿も +含みます。`replies` ジョブでは保存する返信数を制限します。上限に達すると +部分取得として記録し、起点となる投稿の `replies` にフォールバックします。 -デフォルトは`100`です。 +デフォルトは `100` です。 + +### `REMOTE_REPLIES_SCRAPE_MAX_REQUESTS` + +取得の各試行でのドキュメントローダー呼び出し数の上限です。コレクションの +走査と投稿・アクターの保存で共有します。キャッシュと組み込み JSON-LD +コンテキストは除外し、独自の JSON-LD コンテキストは含めます。ローダー +内部のリダイレクト・署名再試行、WebFinger、NodeInfo、メディア取得、 +プレビューカードのリクエストは制限しません。上限に達すると部分取得として +記録し、`replies` にフォールバックします。 + +デフォルトは `200` です。 ### `REMOTE_EMOJI_REACTIONS_FETCH_MAX_ITEMS` @@ -189,10 +205,11 @@ Gukhanmunの読み込みまたは変換に失敗すると、投稿リクエス ### `REMOTE_REPLIES_SCRAPE_COOLDOWN_SECONDS` -完了したリモート返信取得ジョブが同じ返信コレクションの重複ジョブを -抑制する時間。 +完了したリモート取得ジョブが同じコレクションの重複ジョブを抑制する時間です。 +失敗したコンテキストジョブにも適用しますが、起点となる投稿は `replies` に +フォールバックできます。 -デフォルトは`300`です。 +デフォルトは `300` 秒です。 ### `MEDIA_PROXY` diff --git a/docs/src/content/docs/ko/install/env.mdx b/docs/src/content/docs/ko/install/env.mdx index 188e1aca..78b896cb 100644 --- a/docs/src/content/docs/ko/install/env.mdx +++ b/docs/src/content/docs/ko/install/env.mdx @@ -153,17 +153,35 @@ Hollo가 L7 로드 밸런서 뒤에 위치할 경우 (일반적으로 그래야 ### `REMOTE_REPLIES_SCRAPE_DEPTH` -백그라운드 워커 작업에서 가져올 원격 답글의 계층 수입니다. -`0`으로 설정하면 원격 답글 가져오기를 비활성화합니다. +백그라운드에서 가져올 원격 답글의 깊이입니다. 원격 게시물에 FEP-f228 +`context` 컬렉션이 있으면 먼저 읽고, 필요하면 `replies` 컬렉션으로 +대체합니다. `0`으로 설정하면 두 수집 방식 모두 비활성화됩니다. +컨텍스트 컬렉션 밖의 누락된 조상 게시물은 가져오지 않습니다. 부모가 없는 +게시물은 저장하지만 상태 컨텍스트의 답글 트리에서 해당 부모에 연결되지 +않습니다. 기본값은 `2`입니다. ### `REMOTE_REPLIES_SCRAPE_MAX_ITEMS` -원격 답글 가져오기 작업 하나에서 저장할 답글 항목의 최대 수입니다. +컨텍스트 수집 시도 하나에서 순회할 항목의 최대 수입니다. 이미 저장된 +게시물도 포함합니다. `replies` 작업에서는 저장할 답글 항목 수를 제한합니다. +상한에 도달하면 컨텍스트를 부분 수집으로 기록하고 각 시작 게시물의 +`replies` 컬렉션을 대신 수집합니다. 기본값은 `100`입니다. +### `REMOTE_REPLIES_SCRAPE_MAX_REQUESTS` + +수집 시도 하나에서 문서 로더를 호출할 수 있는 최대 횟수입니다. 컬렉션 +순회와 게시물·작성자 저장이 같은 한도를 공유합니다. 캐시된 문서와 내장 +JSON-LD 컨텍스트는 제외하고 사용자 정의 JSON-LD 컨텍스트는 포함합니다. +로더 내부의 리디렉션·서명 재시도, WebFinger, NodeInfo, 미디어 다운로드, +미리보기 카드 요청은 제한하지 않습니다. 한도에 도달하면 컨텍스트를 +부분 수집으로 기록하고 `replies` 컬렉션을 대신 수집합니다. + +기본값은 `200`입니다. + ### `REMOTE_EMOJI_REACTIONS_FETCH_MAX_ITEMS` 원격 게시물을 저장할 때 원격 `emojiReactions` 컬렉션에서 가져와 저장할 @@ -187,8 +205,9 @@ Hollo가 L7 로드 밸런서 뒤에 위치할 경우 (일반적으로 그래야 ### `REMOTE_REPLIES_SCRAPE_COOLDOWN_SECONDS` -완료된 원격 답글 가져오기 작업이 같은 답글 컬렉션의 중복 작업을 -억제하는 시간(초)입니다. +완료된 원격 수집 작업이 같은 컬렉션의 중복 작업을 억제하는 시간(초)입니다. +실패한 컨텍스트 작업에도 적용되지만, 각 시작 게시물은 `replies` 컬렉션을 +대신 수집할 수 있습니다. 기본값은 `300`입니다. diff --git a/docs/src/content/docs/zh-cn/install/env.mdx b/docs/src/content/docs/zh-cn/install/env.mdx index 46c18be2..afc1762a 100644 --- a/docs/src/content/docs/zh-cn/install/env.mdx +++ b/docs/src/content/docs/zh-cn/install/env.mdx @@ -142,15 +142,29 @@ openssl rand -hex 32 ### `REMOTE_REPLIES_SCRAPE_DEPTH` -后台工作器作业抓取远程回复的层级数。设置为`0`可禁用远程回复抓取。 +后台获取远程回复的层数。如果帖子有 FEP-f228 `context` 集合,Hollo 会 +先读取该集合,并在需要时回退到 `replies` 集合。设为 `0` 可禁用这两种 +收集方式。不会获取上下文集合中缺失的祖先帖子。缺少父帖的帖子会保存, +但在状态上下文的回复树中不会连接到该父帖。 -默认为`2`。 +默认值为 `2`。 ### `REMOTE_REPLIES_SCRAPE_MAX_ITEMS` -单个远程回复抓取作业最多保存的回复项目数。 +每次上下文收集尝试遍历的项目数上限,包括已保存的帖子。对于 `replies` +任务,此值限制保存的回复数。达到上限后会记录为部分收集,并回退到 +各起始帖子的 `replies` 集合。 -默认为`100`。 +默认值为 `100`。 + +### `REMOTE_REPLIES_SCRAPE_MAX_REQUESTS` + +每次收集尝试调用文档加载器的次数上限,由集合遍历和帖子、作者保存共享。 +缓存文档和内置 JSON-LD 上下文不计入,自定义 JSON-LD 上下文计入。 +不限制加载器内部的重定向、签名重试,以及 WebFinger、NodeInfo、媒体下载 +或预览卡片请求。达到上限后会记录为部分收集,并回退到 `replies` 集合。 + +默认值为 `200`。 ### `REMOTE_EMOJI_REACTIONS_FETCH_MAX_ITEMS` @@ -174,9 +188,10 @@ openssl rand -hex 32 ### `REMOTE_REPLIES_SCRAPE_COOLDOWN_SECONDS` -已完成的远程回复抓取作业抑制同一回复集合重复作业的时间窗口秒数。 +完成的远程收集任务抑制同一集合重复任务的时间。失败的上下文任务也遵守 +此冷却时间,但各起始帖子仍可回退到 `replies` 集合。 -默认为`300`。 +默认值为 `300` 秒。 ### `MEDIA_PROXY` diff --git a/docs/src/content/docs/zh-tw/install/env.mdx b/docs/src/content/docs/zh-tw/install/env.mdx index 9b4ef1dc..2ceec6c9 100644 --- a/docs/src/content/docs/zh-tw/install/env.mdx +++ b/docs/src/content/docs/zh-tw/install/env.mdx @@ -142,15 +142,29 @@ openssl rand -hex 32 ### `REMOTE_REPLIES_SCRAPE_DEPTH` -後台工作器作業抓取遠端回覆的層級數。設定為 `0` 可停用遠端回覆抓取。 +背景取得遠端回覆的層數。如果貼文有 FEP-f228 `context` 集合,Hollo 會 +先讀取該集合,並在需要時回退至 `replies` 集合。設為 `0` 可停用這兩種 +收集方式。不會取得上下文集合中缺少的祖先貼文。缺少父貼文的貼文會儲存, +但在狀態上下文的回覆樹中不會連接至該父貼文。 -預設為 `2`。 +預設值為 `2`。 ### `REMOTE_REPLIES_SCRAPE_MAX_ITEMS` -單個遠端回覆抓取作業最多儲存的回覆項目數。 +每次上下文收集嘗試遍歷的項目數上限,包含已儲存的貼文。對於 `replies` +工作,此值限制儲存的回覆數。達到上限後會記錄為部分收集,並回退至 +各起始貼文的 `replies` 集合。 -預設為 `100`。 +預設值為 `100`。 + +### `REMOTE_REPLIES_SCRAPE_MAX_REQUESTS` + +每次收集嘗試呼叫文件載入器的次數上限,由集合遍歷與貼文、作者儲存共用。 +快取文件與內建 JSON-LD 上下文不計入,自訂 JSON-LD 上下文計入。 +不限制載入器內部的重新導向、簽章重試,以及 WebFinger、NodeInfo、媒體下載 +或預覽卡片請求。達到上限後會記錄為部分收集,並回退至 `replies` 集合。 + +預設值為 `200`。 ### `REMOTE_EMOJI_REACTIONS_FETCH_MAX_ITEMS` @@ -174,9 +188,10 @@ openssl rand -hex 32 ### `REMOTE_REPLIES_SCRAPE_COOLDOWN_SECONDS` -已完成的遠端回覆抓取作業抑制同一回覆集合重複作業的時間窗口秒數。 +完成的遠端收集工作抑制同一集合重複工作的時間。失敗的上下文工作也遵守 +此冷卻時間,但各起始貼文仍可回退至 `replies` 集合。 -預設為 `300`。 +預設值為 `300` 秒。 ### `MEDIA_PROXY` diff --git a/drizzle/20261008183749_context_backfill/migration.sql b/drizzle/20261008183749_context_backfill/migration.sql new file mode 100644 index 00000000..848c8fe3 --- /dev/null +++ b/drizzle/20261008183749_context_backfill/migration.sql @@ -0,0 +1,21 @@ +CREATE TYPE "remote_reply_scrape_kind" AS ENUM('replies', 'context');--> statement-breakpoint +ALTER TYPE "remote_reply_scrape_job_status" ADD VALUE 'waiting';--> statement-breakpoint +CREATE TABLE "remote_context_scrape_aliases" ( + "iri" text PRIMARY KEY, + "job_id" uuid NOT NULL +); +--> statement-breakpoint +ALTER TABLE "remote_reply_scrape_jobs" DROP CONSTRAINT "remote_reply_scrape_jobs_replies_iri_unique";--> statement-breakpoint +ALTER TABLE "remote_reply_scrape_jobs" ADD COLUMN "kind" "remote_reply_scrape_kind" DEFAULT 'replies'::"remote_reply_scrape_kind" NOT NULL;--> statement-breakpoint +ALTER TABLE "remote_reply_scrape_jobs" ADD COLUMN "blocked_by_job_id" uuid;--> statement-breakpoint +ALTER TABLE "remote_reply_scrape_jobs" ADD COLUMN "partial" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "remote_reply_scrape_jobs" ADD COLUMN "yielded_items" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "remote_reply_scrape_jobs" ADD COLUMN "request_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "remote_reply_scrape_jobs" ADD COLUMN "skipped_items" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "remote_reply_scrape_jobs" ADD COLUMN "host_requeues" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "remote_reply_scrape_origins" ADD COLUMN "cooldown_until" timestamp with time zone DEFAULT 'epoch' NOT NULL;--> statement-breakpoint +CREATE INDEX "remote_context_scrape_aliases_job_id_index" ON "remote_context_scrape_aliases" ("job_id");--> statement-breakpoint +CREATE UNIQUE INDEX "remote_reply_scrape_jobs_kind_collection_unique" ON "remote_reply_scrape_jobs" ("kind","replies_iri");--> statement-breakpoint +CREATE INDEX "remote_reply_scrape_jobs_blocked_by_job_id_index" ON "remote_reply_scrape_jobs" ("blocked_by_job_id");--> statement-breakpoint +ALTER TABLE "remote_context_scrape_aliases" ADD CONSTRAINT "remote_context_scrape_aliases_t0fqdOkRvVea_fkey" FOREIGN KEY ("job_id") REFERENCES "remote_reply_scrape_jobs"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "remote_reply_scrape_jobs" ADD CONSTRAINT "remote_reply_scrape_jobs_FpYh7OnicGJo_fkey" FOREIGN KEY ("blocked_by_job_id") REFERENCES "remote_reply_scrape_jobs"("id") ON DELETE SET NULL; \ No newline at end of file diff --git a/drizzle/20261008183749_context_backfill/snapshot.json b/drizzle/20261008183749_context_backfill/snapshot.json new file mode 100644 index 00000000..21d51ce1 --- /dev/null +++ b/drizzle/20261008183749_context_backfill/snapshot.json @@ -0,0 +1,8361 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "83309487-5aa7-4aa6-b0a2-895a726ddc88", + "prevIds": [ + "5ac66a52-b0c8-4632-9ef4-40ed796162ab" + ], + "ddl": [ + { + "values": [ + "Application", + "Group", + "Organization", + "Person", + "Service" + ], + "name": "account_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "cleanup_thumbnails" + ], + "name": "cleanup_job_category", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "processing", + "completed", + "failed", + "cancelled" + ], + "name": "cleanup_job_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "authorization_code", + "client_credentials" + ], + "name": "grant_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "following_accounts", + "lists", + "muted_accounts", + "blocked_accounts", + "bookmarks" + ], + "name": "import_job_category", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "processing", + "completed", + "failed", + "cancelled" + ], + "name": "import_job_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "followed", + "list", + "none" + ], + "name": "list_replies_policy", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "notifications", + "home" + ], + "name": "marker_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "mention", + "status", + "reblog", + "follow", + "follow_request", + "favourite", + "emoji_reaction", + "poll", + "update", + "admin.sign_up", + "admin.report", + "quote", + "quoted_update" + ], + "name": "notification_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "Article", + "Note", + "Question" + ], + "name": "post_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "public", + "unlisted", + "private", + "direct" + ], + "name": "post_visibility", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "public", + "followers", + "nobody" + ], + "name": "quote_approval_policy", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "accepted", + "rejected", + "revoked", + "unauthorized" + ], + "name": "quote_state", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "processing", + "completed", + "failed", + "waiting" + ], + "name": "remote_reply_scrape_job_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "replies", + "context" + ], + "name": "remote_reply_scrape_kind", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "read", + "read:accounts", + "read:blocks", + "read:bookmarks", + "read:favourites", + "read:filters", + "read:follows", + "read:lists", + "read:mutes", + "read:notifications", + "read:search", + "read:statuses", + "write", + "write:accounts", + "write:blocks", + "write:bookmarks", + "write:conversations", + "write:favourites", + "write:filters", + "write:follows", + "write:lists", + "write:media", + "write:mutes", + "write:notifications", + "write:reports", + "write:statuses", + "follow", + "push", + "profile" + ], + "name": "scope", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "amber", + "azure", + "blue", + "cyan", + "fuchsia", + "green", + "grey", + "indigo", + "jade", + "lime", + "orange", + "pink", + "pumpkin", + "purple", + "red", + "sand", + "slate", + "violet", + "yellow", + "zinc" + ], + "name": "theme_color", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "access_grants", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "access_tokens", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "account_owners", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "applications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "blocks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "bookmarks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cleanup_job_items", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cleanup_jobs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "credentials", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "custom_emojis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "featured_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "follows", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "import_job_effects", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "import_job_items", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "import_jobs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "likes", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "list_members", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "list_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "lists", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "markers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "media", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "mentions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "mutes", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notification_groups", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "passkey_login_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pinned_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "poll_options", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "poll_votes", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "polls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "reactions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "remote_context_scrape_aliases", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "remote_reply_scrape_jobs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "remote_reply_scrape_origins", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "reports", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "timeline_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "totps", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "access_grants" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "access_grants" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_in", + "entityType": "columns", + "schema": "public", + "table": "access_grants" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "redirect_uri", + "entityType": "columns", + "schema": "public", + "table": "access_grants" + }, + { + "type": "scope", + "typeSchema": "public", + "notNull": true, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "scopes", + "entityType": "columns", + "schema": "public", + "table": "access_grants" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code_challenge", + "entityType": "columns", + "schema": "public", + "table": "access_grants" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code_challenge_method", + "entityType": "columns", + "schema": "public", + "table": "access_grants" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "application_id", + "entityType": "columns", + "schema": "public", + "table": "access_grants" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resource_owner_id", + "entityType": "columns", + "schema": "public", + "table": "access_grants" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "access_grants" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked", + "entityType": "columns", + "schema": "public", + "table": "access_grants" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "access_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "access_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "application_id", + "entityType": "columns", + "schema": "public", + "table": "access_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_owner_id", + "entityType": "columns", + "schema": "public", + "table": "access_tokens" + }, + { + "type": "grant_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'authorization_code'", + "generated": null, + "identity": null, + "name": "grant_type", + "entityType": "columns", + "schema": "public", + "table": "access_tokens" + }, + { + "type": "scope", + "typeSchema": "public", + "notNull": true, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "scopes", + "entityType": "columns", + "schema": "public", + "table": "access_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "access_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "handle", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rsa_private_key_jwk", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rsa_public_key_jwk", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ed25519_private_key_jwk", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ed25519_public_key_jwk", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "json", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bio", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "followed_tags", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "post_visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'public'", + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "discoverable", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "expand_spoilers", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "following_list_public", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "theme_color", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "theme_color", + "entityType": "columns", + "schema": "public", + "table": "account_owners" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "account_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "handle", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bio_html", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar_url", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cover_url", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inbox_url", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "followers_url", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "shared_inbox_url", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "featured_url", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "following_count", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "followers_count", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "posts_count", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "json", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "field_htmls", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "emojis", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "successor_id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "(ARRAY[]::text[])", + "generated": null, + "identity": null, + "name": "aliases", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instance_host", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fetched", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "applications" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "applications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "redirect_uris", + "entityType": "columns", + "schema": "public", + "table": "applications" + }, + { + "type": "scope", + "typeSchema": "public", + "notNull": true, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "scopes", + "entityType": "columns", + "schema": "public", + "table": "applications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "website", + "entityType": "columns", + "schema": "public", + "table": "applications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_id", + "entityType": "columns", + "schema": "public", + "table": "applications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_secret", + "entityType": "columns", + "schema": "public", + "table": "applications" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "confidential", + "entityType": "columns", + "schema": "public", + "table": "applications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "applications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "blocked_account_id", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "blocks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "bookmarks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_owner_id", + "entityType": "columns", + "schema": "public", + "table": "bookmarks" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "bookmarks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "job_id", + "entityType": "columns", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "type": "cleanup_job_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_message", + "entityType": "columns", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processed_at", + "entityType": "columns", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "next_attempt_at", + "entityType": "columns", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "next_dispatch_at", + "entityType": "columns", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "type": "cleanup_job_category", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "type": "cleanup_job_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "total_items", + "entityType": "columns", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "processed_items", + "entityType": "columns", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "successful_items", + "entityType": "columns", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "failed_items", + "entityType": "columns", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_message", + "entityType": "columns", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completed_at", + "entityType": "columns", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "next_dispatch_at", + "entityType": "columns", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "type": "varchar(254)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password_hash", + "entityType": "columns", + "schema": "public", + "table": "credentials" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "shortcode", + "entityType": "columns", + "schema": "public", + "table": "custom_emojis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "custom_emojis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "custom_emojis" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "custom_emojis" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "featured_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_owner_id", + "entityType": "columns", + "schema": "public", + "table": "featured_tags" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "featured_tags" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "featured_tags" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "follows" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "following_id", + "entityType": "columns", + "schema": "public", + "table": "follows" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "follower_id", + "entityType": "columns", + "schema": "public", + "table": "follows" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "shares", + "entityType": "columns", + "schema": "public", + "table": "follows" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "notify", + "entityType": "columns", + "schema": "public", + "table": "follows" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "languages", + "entityType": "columns", + "schema": "public", + "table": "follows" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "follows" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved", + "entityType": "columns", + "schema": "public", + "table": "follows" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "item_id", + "entityType": "columns", + "schema": "public", + "table": "import_job_effects" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "deliveries", + "entityType": "columns", + "schema": "public", + "table": "import_job_effects" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "delivered", + "entityType": "columns", + "schema": "public", + "table": "import_job_effects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "import_job_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "job_id", + "entityType": "columns", + "schema": "public", + "table": "import_job_items" + }, + { + "type": "import_job_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "import_job_items" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "import_job_items" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_message", + "entityType": "columns", + "schema": "public", + "table": "import_job_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processed_at", + "entityType": "columns", + "schema": "public", + "table": "import_job_items" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "import_job_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "next_attempt_at", + "entityType": "columns", + "schema": "public", + "table": "import_job_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "next_dispatch_at", + "entityType": "columns", + "schema": "public", + "table": "import_job_items" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "import_job_items" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_owner_id", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "import_job_category", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "import_job_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "total_items", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "processed_items", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "successful_items", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "failed_items", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_message", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completed_at", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "next_dispatch_at", + "entityType": "columns", + "schema": "public", + "table": "import_jobs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "host", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "software", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "software_version", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "likes" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "likes" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "likes" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "list_id", + "entityType": "columns", + "schema": "public", + "table": "list_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "list_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "list_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "list_id", + "entityType": "columns", + "schema": "public", + "table": "list_posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "list_posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "lists" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_owner_id", + "entityType": "columns", + "schema": "public", + "table": "lists" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "lists" + }, + { + "type": "list_replies_policy", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'list'", + "generated": null, + "identity": null, + "name": "replies_policy", + "entityType": "columns", + "schema": "public", + "table": "lists" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "exclusive", + "entityType": "columns", + "schema": "public", + "table": "lists" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "lists" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_owner_id", + "entityType": "columns", + "schema": "public", + "table": "markers" + }, + { + "type": "marker_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "markers" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_read_id", + "entityType": "columns", + "schema": "public", + "table": "markers" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "markers" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "markers" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "width", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "height", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_type", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_width", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_height", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "thumbnail_cleaned", + "entityType": "columns", + "schema": "public", + "table": "media" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "mentions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "mentions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "mutes" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "mutes" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "muted_account_id", + "entityType": "columns", + "schema": "public", + "table": "mutes" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notifications", + "entityType": "columns", + "schema": "public", + "table": "mutes" + }, + { + "type": "interval", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "duration", + "entityType": "columns", + "schema": "public", + "table": "mutes" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "mutes" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "group_key", + "entityType": "columns", + "schema": "public", + "table": "notification_groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_owner_id", + "entityType": "columns", + "schema": "public", + "table": "notification_groups" + }, + { + "type": "notification_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "notification_groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target_post_id", + "entityType": "columns", + "schema": "public", + "table": "notification_groups" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "notifications_count", + "entityType": "columns", + "schema": "public", + "table": "notification_groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "most_recent_notification_id", + "entityType": "columns", + "schema": "public", + "table": "notification_groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "sample_account_ids", + "entityType": "columns", + "schema": "public", + "table": "notification_groups" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latest_page_notification_at", + "entityType": "columns", + "schema": "public", + "table": "notification_groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "page_min_id", + "entityType": "columns", + "schema": "public", + "table": "notification_groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "page_max_id", + "entityType": "columns", + "schema": "public", + "table": "notification_groups" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "notification_groups" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "notification_groups" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_owner_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "notification_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_account_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target_post_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target_account_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target_poll_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "group_key", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "read_at", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "passkey_login_challenges" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "passkey_login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "passkey_login_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "passkey_login_challenges" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(254)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_email", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_key", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "(ARRAY[]::text[])", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_type", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "backed_up", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nickname", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_used", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "index", + "entityType": "columns", + "schema": "public", + "table": "pinned_posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "pinned_posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "pinned_posts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "pinned_posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "poll_id", + "entityType": "columns", + "schema": "public", + "table": "poll_options" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "index", + "entityType": "columns", + "schema": "public", + "table": "poll_options" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "poll_options" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "votes_count", + "entityType": "columns", + "schema": "public", + "table": "poll_options" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "poll_id", + "entityType": "columns", + "schema": "public", + "table": "poll_votes" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "option_index", + "entityType": "columns", + "schema": "public", + "table": "poll_votes" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "poll_votes" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "poll_votes" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "polls" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "multiple", + "entityType": "columns", + "schema": "public", + "table": "polls" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "voters_count", + "entityType": "columns", + "schema": "public", + "table": "polls" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "polls" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "polls" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "post_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "application_id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reply_target_id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sharing_id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "quote_target_id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "quote_target_iri", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "quote_state", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "quote_state", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "quote_authorization_iri", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "quote_approval_policy", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": "'public'", + "generated": null, + "identity": null, + "name": "quote_approval_policy", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "post_visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "summary", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content_html", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "poll_id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tags", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "emojis", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "sensitive", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview_card", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "replies_count", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "shares_count", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "likes_count", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "quotes_count", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotence_key", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "reactions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "reactions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emoji", + "entityType": "columns", + "schema": "public", + "table": "reactions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "custom_emoji", + "entityType": "columns", + "schema": "public", + "table": "reactions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emoji_iri", + "entityType": "columns", + "schema": "public", + "table": "reactions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "reactions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "remote_context_scrape_aliases" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "job_id", + "entityType": "columns", + "schema": "public", + "table": "remote_context_scrape_aliases" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_iri", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "replies_iri", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "remote_reply_scrape_kind", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'replies'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "blocked_by_job_id", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "partial", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "yielded_items", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "request_count", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "skipped_items", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "host_requeues", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "base_url", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin_host", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "depth", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "remote_reply_scrape_job_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "fetched_items", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "next_attempt_at", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "next_dispatch_at", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_message", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completed_at", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin_host", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_origins" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "next_request_at", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_origins" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_request_at", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_origins" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'epoch'", + "generated": null, + "identity": null, + "name": "cooldown_until", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_origins" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processing_job_id", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_origins" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processing_started_at", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_origins" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "updated", + "entityType": "columns", + "schema": "public", + "table": "remote_reply_scrape_origins" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iri", + "entityType": "columns", + "schema": "public", + "table": "reports" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "reports" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target_account_id", + "entityType": "columns", + "schema": "public", + "table": "reports" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "comment", + "entityType": "columns", + "schema": "public", + "table": "reports" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "posts", + "entityType": "columns", + "schema": "public", + "table": "reports" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "timeline_posts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_id", + "entityType": "columns", + "schema": "public", + "table": "timeline_posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issuer", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "algorithm", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "smallint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "digits", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "smallint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "period", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "resource_owner_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "access_grants_resource_owner_id_index", + "entityType": "indexes", + "schema": "public", + "table": "access_grants" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "application_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "access_grants_application_id_index", + "entityType": "indexes", + "schema": "public", + "table": "access_grants" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "application_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "access_tokens_application_id_created_id_index", + "entityType": "indexes", + "schema": "public", + "table": "access_tokens" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "account_owner_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "access_tokens_account_owner_id_index", + "entityType": "indexes", + "schema": "public", + "table": "access_tokens" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "created", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "access_tokens_created_id_index", + "entityType": "indexes", + "schema": "public", + "table": "access_tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "handle", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": { + "name": "gin_trgm_ops", + "default": false + } + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "accounts_handle_trgm_idx", + "entityType": "indexes", + "schema": "public", + "table": "accounts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": { + "name": "gin_trgm_ops", + "default": false + } + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "accounts_name_trgm_idx", + "entityType": "indexes", + "schema": "public", + "table": "accounts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blocks_account_id_index", + "entityType": "indexes", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "blocked_account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blocks_blocked_account_id_index", + "entityType": "indexes", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "account_owner_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "bookmarks_post_id_account_owner_id_index", + "entityType": "indexes", + "schema": "public", + "table": "bookmarks" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cleanup_job_items_job_id_status_index", + "entityType": "indexes", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "next_dispatch_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"status\" IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "cleanup_job_items_job_id_next_dispatch_at_index", + "entityType": "indexes", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "(\"data\"->>'key')", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"data\"->>'kind' = 'proxy_cache'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "cleanup_job_items_proxy_key_index", + "entityType": "indexes", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cleanup_jobs_status_created_index", + "entityType": "indexes", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "next_dispatch_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"status\" IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "cleanup_jobs_next_dispatch_at_index", + "entityType": "indexes", + "schema": "public", + "table": "cleanup_jobs" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "following_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "approved", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "(\"approved\" is not null)", + "with": "", + "method": "btree", + "concurrently": false, + "name": "follows_following_id_approved_index", + "entityType": "indexes", + "schema": "public", + "table": "follows" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "follower_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "following_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "(\"approved\" is not null)", + "with": "", + "method": "btree", + "concurrently": false, + "name": "follows_follower_id_following_id_approved_index", + "entityType": "indexes", + "schema": "public", + "table": "follows" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "following_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "follows_following_id_created_index", + "entityType": "indexes", + "schema": "public", + "table": "follows" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "item_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"delivered\" < jsonb_array_length(\"deliveries\")", + "with": "", + "method": "btree", + "concurrently": false, + "name": "import_job_effects_item_id_index", + "entityType": "indexes", + "schema": "public", + "table": "import_job_effects" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "import_job_items_job_id_status_index", + "entityType": "indexes", + "schema": "public", + "table": "import_job_items" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "next_dispatch_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"status\" IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "import_job_items_job_id_next_dispatch_at_index", + "entityType": "indexes", + "schema": "public", + "table": "import_job_items" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "account_owner_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "import_jobs_account_owner_id_status_index", + "entityType": "indexes", + "schema": "public", + "table": "import_jobs" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "import_jobs_status_created_index", + "entityType": "indexes", + "schema": "public", + "table": "import_jobs" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "next_dispatch_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"status\" IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "import_jobs_next_dispatch_at_index", + "entityType": "indexes", + "schema": "public", + "table": "import_jobs" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "likes_account_id_post_id_index", + "entityType": "indexes", + "schema": "public", + "table": "likes" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "created", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "likes_created_index", + "entityType": "indexes", + "schema": "public", + "table": "likes" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "list_posts_post_id_index", + "entityType": "indexes", + "schema": "public", + "table": "list_posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "list_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "list_posts_list_id_post_id_index", + "entityType": "indexes", + "schema": "public", + "table": "list_posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "media_post_id_index", + "entityType": "indexes", + "schema": "public", + "table": "media" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mentions_post_id_account_id_index", + "entityType": "indexes", + "schema": "public", + "table": "mentions" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mutes_account_id_index", + "entityType": "indexes", + "schema": "public", + "table": "mutes" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "target_post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notification_groups_target_post_id_index", + "entityType": "indexes", + "schema": "public", + "table": "notification_groups" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "account_owner_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "updated", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notification_groups_account_owner_id_updated_index", + "entityType": "indexes", + "schema": "public", + "table": "notification_groups" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "account_owner_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notification_groups_account_owner_id_type_index", + "entityType": "indexes", + "schema": "public", + "table": "notification_groups" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "target_post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_target_post_id_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "account_owner_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_account_owner_id_created_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "account_owner_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "read_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_account_owner_id_read_at_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "group_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_group_key_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "created", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_created_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "account_owner_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "target_poll_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"type\" = 'poll' AND \"target_poll_id\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_poll_account_owner_id_target_poll_id_unique", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "passkey_login_challenges_expires_at_index", + "entityType": "indexes", + "schema": "public", + "table": "passkey_login_challenges" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pinned_posts_account_id_post_id_index", + "entityType": "indexes", + "schema": "public", + "table": "pinned_posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "poll_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "index", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "poll_options_poll_id_index_index", + "entityType": "indexes", + "schema": "public", + "table": "poll_options" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "poll_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "poll_votes_poll_id_account_id_index", + "entityType": "indexes", + "schema": "public", + "table": "poll_votes" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "polls_expires_index", + "entityType": "indexes", + "schema": "public", + "table": "polls" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "sharing_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_sharing_id_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_actor_id_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "url", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "(\"url\" is not null)", + "with": "", + "method": "hash", + "concurrently": false, + "name": "posts_url_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "sharing_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_actor_id_sharing_id_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "reply_target_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_reply_target_id_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reply_target_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_actor_id_reply_target_id_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "quote_target_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "(\"quote_target_id\" is not null)", + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_quote_target_id_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "quote_authorization_iri", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "(\"quote_authorization_iri\" is not null)", + "with": "", + "method": "hash", + "concurrently": false, + "name": "posts_quote_authorization_iri_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "visibility", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_visibility_actor_id_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "visibility", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "sharing_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "(\"sharing_id\" is not null)", + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_visibility_actor_id_sharing_id_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "visibility", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reply_target_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "(\"reply_target_id\" is not null)", + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_visibility_actor_id_reply_target_id_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "content_html", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": { + "name": "gin_trgm_ops", + "default": false + } + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "posts_content_html_trgm_idx", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_updated_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "updated", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_actor_id_updated_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "published", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_actor_id_published_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "(\"language\" is not null)", + "with": "", + "method": "btree", + "concurrently": false, + "name": "posts_actor_id_language_index", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tags", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "posts_tags_gin_idx", + "entityType": "indexes", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "reactions_post_id_index", + "entityType": "indexes", + "schema": "public", + "table": "reactions" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "reactions_post_id_account_id_index", + "entityType": "indexes", + "schema": "public", + "table": "reactions" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "created", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "reactions_created_index", + "entityType": "indexes", + "schema": "public", + "table": "reactions" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "remote_context_scrape_aliases_job_id_index", + "entityType": "indexes", + "schema": "public", + "table": "remote_context_scrape_aliases" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "replies_iri", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "remote_reply_scrape_jobs_kind_collection_unique", + "entityType": "indexes", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "blocked_by_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "remote_reply_scrape_jobs_blocked_by_job_id_index", + "entityType": "indexes", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "remote_reply_scrape_jobs_post_id_index", + "entityType": "indexes", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "next_dispatch_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"status\" IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "remote_reply_scrape_jobs_dispatch_index", + "entityType": "indexes", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "next_attempt_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "remote_reply_scrape_jobs_claim_index", + "entityType": "indexes", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "origin_host", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "next_attempt_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "remote_reply_scrape_jobs_origin_claim_index", + "entityType": "indexes", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "updated", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "remote_reply_scrape_jobs_stale_processing_index", + "entityType": "indexes", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "next_request_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "remote_reply_scrape_origins_next_request_at_index", + "entityType": "indexes", + "schema": "public", + "table": "remote_reply_scrape_origins" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "processing_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "remote_reply_scrape_origins_processing_job_id_index", + "entityType": "indexes", + "schema": "public", + "table": "remote_reply_scrape_origins" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "timeline_posts_post_id_index", + "entityType": "indexes", + "schema": "public", + "table": "timeline_posts" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "post_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "timeline_posts_account_id_post_id_index", + "entityType": "indexes", + "schema": "public", + "table": "timeline_posts" + }, + { + "nameExplicit": false, + "columns": [ + "application_id" + ], + "schemaTo": "public", + "tableTo": "applications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "access_grants_application_id_applications_id_fk", + "entityType": "fks", + "schema": "public", + "table": "access_grants" + }, + { + "nameExplicit": false, + "columns": [ + "resource_owner_id" + ], + "schemaTo": "public", + "tableTo": "account_owners", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "access_grants_resource_owner_id_account_owners_id_fk", + "entityType": "fks", + "schema": "public", + "table": "access_grants" + }, + { + "nameExplicit": false, + "columns": [ + "application_id" + ], + "schemaTo": "public", + "tableTo": "applications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "access_tokens_application_id_applications_id_fk", + "entityType": "fks", + "schema": "public", + "table": "access_tokens" + }, + { + "nameExplicit": false, + "columns": [ + "account_owner_id" + ], + "schemaTo": "public", + "tableTo": "account_owners", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "access_tokens_account_owner_id_account_owners_id_fk", + "entityType": "fks", + "schema": "public", + "table": "access_tokens" + }, + { + "nameExplicit": false, + "columns": [ + "id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "account_owners_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "account_owners" + }, + { + "nameExplicit": false, + "columns": [ + "successor_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "accounts_successor_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "accounts" + }, + { + "nameExplicit": false, + "columns": [ + "instance_host" + ], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": [ + "host" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "accounts_instance_host_instances_host_fk", + "entityType": "fks", + "schema": "public", + "table": "accounts" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "blocks_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": false, + "columns": [ + "blocked_account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "blocks_blocked_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blocks" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "bookmarks_post_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "bookmarks" + }, + { + "nameExplicit": false, + "columns": [ + "account_owner_id" + ], + "schemaTo": "public", + "tableTo": "account_owners", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "bookmarks_account_owner_id_account_owners_id_fk", + "entityType": "fks", + "schema": "public", + "table": "bookmarks" + }, + { + "nameExplicit": false, + "columns": [ + "job_id" + ], + "schemaTo": "public", + "tableTo": "cleanup_jobs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "cleanup_job_items_job_id_cleanup_jobs_id_fk", + "entityType": "fks", + "schema": "public", + "table": "cleanup_job_items" + }, + { + "nameExplicit": false, + "columns": [ + "account_owner_id" + ], + "schemaTo": "public", + "tableTo": "account_owners", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "featured_tags_account_owner_id_account_owners_id_fk", + "entityType": "fks", + "schema": "public", + "table": "featured_tags" + }, + { + "nameExplicit": false, + "columns": [ + "following_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "follows_following_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "follows" + }, + { + "nameExplicit": false, + "columns": [ + "follower_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "follows_follower_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "follows" + }, + { + "nameExplicit": false, + "columns": [ + "item_id" + ], + "schemaTo": "public", + "tableTo": "import_job_items", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "import_job_effects_item_id_import_job_items_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "import_job_effects" + }, + { + "nameExplicit": false, + "columns": [ + "job_id" + ], + "schemaTo": "public", + "tableTo": "import_jobs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "import_job_items_job_id_import_jobs_id_fk", + "entityType": "fks", + "schema": "public", + "table": "import_job_items" + }, + { + "nameExplicit": false, + "columns": [ + "account_owner_id" + ], + "schemaTo": "public", + "tableTo": "account_owners", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "import_jobs_account_owner_id_account_owners_id_fk", + "entityType": "fks", + "schema": "public", + "table": "import_jobs" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "likes_post_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "likes" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "likes_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "likes" + }, + { + "nameExplicit": false, + "columns": [ + "list_id" + ], + "schemaTo": "public", + "tableTo": "lists", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "list_members_list_id_lists_id_fk", + "entityType": "fks", + "schema": "public", + "table": "list_members" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "list_members_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "list_members" + }, + { + "nameExplicit": false, + "columns": [ + "list_id" + ], + "schemaTo": "public", + "tableTo": "lists", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "list_posts_list_id_lists_id_fk", + "entityType": "fks", + "schema": "public", + "table": "list_posts" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "list_posts_post_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "list_posts" + }, + { + "nameExplicit": false, + "columns": [ + "account_owner_id" + ], + "schemaTo": "public", + "tableTo": "account_owners", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "lists_account_owner_id_account_owners_id_fk", + "entityType": "fks", + "schema": "public", + "table": "lists" + }, + { + "nameExplicit": false, + "columns": [ + "account_owner_id" + ], + "schemaTo": "public", + "tableTo": "account_owners", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "markers_account_owner_id_account_owners_id_fk", + "entityType": "fks", + "schema": "public", + "table": "markers" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "media_post_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "media" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "mentions_post_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "mentions" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "mentions_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "mentions" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "mutes_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "mutes" + }, + { + "nameExplicit": false, + "columns": [ + "muted_account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "mutes_muted_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "mutes" + }, + { + "nameExplicit": false, + "columns": [ + "account_owner_id" + ], + "schemaTo": "public", + "tableTo": "account_owners", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notification_groups_account_owner_id_account_owners_id_fk", + "entityType": "fks", + "schema": "public", + "table": "notification_groups" + }, + { + "nameExplicit": false, + "columns": [ + "target_post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notification_groups_target_post_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "notification_groups" + }, + { + "nameExplicit": false, + "columns": [ + "most_recent_notification_id" + ], + "schemaTo": "public", + "tableTo": "notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notification_groups_most_recent_notification_id_notifications_i", + "entityType": "fks", + "schema": "public", + "table": "notification_groups" + }, + { + "nameExplicit": false, + "columns": [ + "account_owner_id" + ], + "schemaTo": "public", + "tableTo": "account_owners", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_account_owner_id_account_owners_id_fk", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "actor_account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_actor_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "target_post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_target_post_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "target_account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_target_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "target_poll_id" + ], + "schemaTo": "public", + "tableTo": "polls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_target_poll_id_polls_id_fk", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "credential_email" + ], + "schemaTo": "public", + "tableTo": "credentials", + "columnsTo": [ + "email" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "passkeys_credential_email_credentials_email_fk", + "entityType": "fks", + "schema": "public", + "table": "passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pinned_posts_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "pinned_posts" + }, + { + "nameExplicit": false, + "columns": [ + "post_id", + "account_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id", + "actor_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "pinned_posts_post_id_account_id_posts_id_actor_id_fk", + "entityType": "fks", + "schema": "public", + "table": "pinned_posts" + }, + { + "nameExplicit": false, + "columns": [ + "poll_id" + ], + "schemaTo": "public", + "tableTo": "polls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "poll_options_poll_id_polls_id_fk", + "entityType": "fks", + "schema": "public", + "table": "poll_options" + }, + { + "nameExplicit": false, + "columns": [ + "poll_id" + ], + "schemaTo": "public", + "tableTo": "polls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "poll_votes_poll_id_polls_id_fk", + "entityType": "fks", + "schema": "public", + "table": "poll_votes" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "poll_votes_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "poll_votes" + }, + { + "nameExplicit": false, + "columns": [ + "poll_id", + "option_index" + ], + "schemaTo": "public", + "tableTo": "poll_options", + "columnsTo": [ + "poll_id", + "index" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "poll_votes_poll_id_option_index_poll_options_poll_id_index_fk", + "entityType": "fks", + "schema": "public", + "table": "poll_votes" + }, + { + "nameExplicit": false, + "columns": [ + "actor_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "posts_actor_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + "application_id" + ], + "schemaTo": "public", + "tableTo": "applications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "posts_application_id_applications_id_fk", + "entityType": "fks", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + "reply_target_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "posts_reply_target_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + "sharing_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "posts_sharing_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + "quote_target_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "posts_quote_target_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + "poll_id" + ], + "schemaTo": "public", + "tableTo": "polls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "posts_poll_id_polls_id_fk", + "entityType": "fks", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "reactions_post_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "reactions" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "reactions_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "reactions" + }, + { + "nameExplicit": false, + "columns": [ + "job_id" + ], + "schemaTo": "public", + "tableTo": "remote_reply_scrape_jobs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "remote_context_scrape_aliases_t0fqdOkRvVea_fkey", + "entityType": "fks", + "schema": "public", + "table": "remote_context_scrape_aliases" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "remote_reply_scrape_jobs_post_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "nameExplicit": false, + "columns": [ + "blocked_by_job_id" + ], + "schemaTo": "public", + "tableTo": "remote_reply_scrape_jobs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "remote_reply_scrape_jobs_FpYh7OnicGJo_fkey", + "entityType": "fks", + "schema": "public", + "table": "remote_reply_scrape_jobs" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "reports_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "reports" + }, + { + "nameExplicit": false, + "columns": [ + "target_account_id" + ], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "reports_target_account_id_accounts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "reports" + }, + { + "nameExplicit": false, + "columns": [ + "account_id" + ], + "schemaTo": "public", + "tableTo": "account_owners", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "timeline_posts_account_id_account_owners_id_fk", + "entityType": "fks", + "schema": "public", + "table": "timeline_posts" + }, + { + "nameExplicit": false, + "columns": [ + "post_id" + ], + "schemaTo": "public", + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "timeline_posts_post_id_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "timeline_posts" + }, + { + "columns": [ + "account_id", + "blocked_account_id" + ], + "nameExplicit": false, + "name": "blocks_account_id_blocked_account_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blocks" + }, + { + "columns": [ + "post_id", + "account_owner_id" + ], + "nameExplicit": false, + "name": "bookmarks_post_id_account_owner_id_pk", + "entityType": "pks", + "schema": "public", + "table": "bookmarks" + }, + { + "columns": [ + "following_id", + "follower_id" + ], + "nameExplicit": false, + "name": "follows_following_id_follower_id_pk", + "entityType": "pks", + "schema": "public", + "table": "follows" + }, + { + "columns": [ + "post_id", + "account_id" + ], + "nameExplicit": false, + "name": "likes_post_id_account_id_pk", + "entityType": "pks", + "schema": "public", + "table": "likes" + }, + { + "columns": [ + "list_id", + "account_id" + ], + "nameExplicit": false, + "name": "list_members_list_id_account_id_pk", + "entityType": "pks", + "schema": "public", + "table": "list_members" + }, + { + "columns": [ + "list_id", + "post_id" + ], + "nameExplicit": false, + "name": "list_posts_list_id_post_id_pk", + "entityType": "pks", + "schema": "public", + "table": "list_posts" + }, + { + "columns": [ + "account_owner_id", + "type" + ], + "nameExplicit": false, + "name": "markers_account_owner_id_type_pk", + "entityType": "pks", + "schema": "public", + "table": "markers" + }, + { + "columns": [ + "post_id", + "account_id" + ], + "nameExplicit": false, + "name": "mentions_post_id_account_id_pk", + "entityType": "pks", + "schema": "public", + "table": "mentions" + }, + { + "columns": [ + "poll_id", + "index" + ], + "nameExplicit": false, + "name": "poll_options_poll_id_index_pk", + "entityType": "pks", + "schema": "public", + "table": "poll_options" + }, + { + "columns": [ + "poll_id", + "option_index", + "account_id" + ], + "nameExplicit": false, + "name": "poll_votes_poll_id_option_index_account_id_pk", + "entityType": "pks", + "schema": "public", + "table": "poll_votes" + }, + { + "columns": [ + "post_id", + "account_id", + "emoji" + ], + "nameExplicit": false, + "name": "reactions_post_id_account_id_emoji_pk", + "entityType": "pks", + "schema": "public", + "table": "reactions" + }, + { + "columns": [ + "account_id", + "post_id" + ], + "nameExplicit": false, + "name": "timeline_posts_account_id_post_id_pk", + "entityType": "pks", + "schema": "public", + "table": "timeline_posts" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "access_grants_pkey", + "schema": "public", + "table": "access_grants", + "entityType": "pks" + }, + { + "columns": [ + "code" + ], + "nameExplicit": false, + "name": "access_tokens_pkey", + "schema": "public", + "table": "access_tokens", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_owners_pkey", + "schema": "public", + "table": "account_owners", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "accounts_pkey", + "schema": "public", + "table": "accounts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "applications_pkey", + "schema": "public", + "table": "applications", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cleanup_job_items_pkey", + "schema": "public", + "table": "cleanup_job_items", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cleanup_jobs_pkey", + "schema": "public", + "table": "cleanup_jobs", + "entityType": "pks" + }, + { + "columns": [ + "email" + ], + "nameExplicit": false, + "name": "credentials_pkey", + "schema": "public", + "table": "credentials", + "entityType": "pks" + }, + { + "columns": [ + "shortcode" + ], + "nameExplicit": false, + "name": "custom_emojis_pkey", + "schema": "public", + "table": "custom_emojis", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "featured_tags_pkey", + "schema": "public", + "table": "featured_tags", + "entityType": "pks" + }, + { + "columns": [ + "item_id" + ], + "nameExplicit": false, + "name": "import_job_effects_pkey", + "schema": "public", + "table": "import_job_effects", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "import_job_items_pkey", + "schema": "public", + "table": "import_job_items", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "import_jobs_pkey", + "schema": "public", + "table": "import_jobs", + "entityType": "pks" + }, + { + "columns": [ + "host" + ], + "nameExplicit": false, + "name": "instances_pkey", + "schema": "public", + "table": "instances", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "lists_pkey", + "schema": "public", + "table": "lists", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "media_pkey", + "schema": "public", + "table": "media", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "mutes_pkey", + "schema": "public", + "table": "mutes", + "entityType": "pks" + }, + { + "columns": [ + "group_key" + ], + "nameExplicit": false, + "name": "notification_groups_pkey", + "schema": "public", + "table": "notification_groups", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "notifications_pkey", + "schema": "public", + "table": "notifications", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "passkey_login_challenges_pkey", + "schema": "public", + "table": "passkey_login_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "passkeys_pkey", + "schema": "public", + "table": "passkeys", + "entityType": "pks" + }, + { + "columns": [ + "index" + ], + "nameExplicit": false, + "name": "pinned_posts_pkey", + "schema": "public", + "table": "pinned_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "polls_pkey", + "schema": "public", + "table": "polls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "posts_pkey", + "schema": "public", + "table": "posts", + "entityType": "pks" + }, + { + "columns": [ + "iri" + ], + "nameExplicit": false, + "name": "remote_context_scrape_aliases_pkey", + "schema": "public", + "table": "remote_context_scrape_aliases", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "remote_reply_scrape_jobs_pkey", + "schema": "public", + "table": "remote_reply_scrape_jobs", + "entityType": "pks" + }, + { + "columns": [ + "origin_host" + ], + "nameExplicit": false, + "name": "remote_reply_scrape_origins_pkey", + "schema": "public", + "table": "remote_reply_scrape_origins", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "reports_pkey", + "schema": "public", + "table": "reports", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": [ + "account_owner_id", + "name" + ], + "nullsNotDistinct": false, + "name": "featured_tags_account_owner_id_name_unique", + "entityType": "uniques", + "schema": "public", + "table": "featured_tags" + }, + { + "nameExplicit": true, + "columns": [ + "account_id", + "muted_account_id" + ], + "nullsNotDistinct": false, + "name": "mutes_account_id_muted_account_id_unique", + "entityType": "uniques", + "schema": "public", + "table": "mutes" + }, + { + "nameExplicit": false, + "columns": [ + "post_id", + "account_id" + ], + "nullsNotDistinct": false, + "name": "pinned_posts_post_id_account_id_unique", + "entityType": "uniques", + "schema": "public", + "table": "pinned_posts" + }, + { + "nameExplicit": false, + "columns": [ + "poll_id", + "title" + ], + "nullsNotDistinct": false, + "name": "poll_options_poll_id_title_unique", + "entityType": "uniques", + "schema": "public", + "table": "poll_options" + }, + { + "nameExplicit": true, + "columns": [ + "id", + "actor_id" + ], + "nullsNotDistinct": false, + "name": "posts_id_actor_id_unique", + "entityType": "uniques", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + "poll_id" + ], + "nullsNotDistinct": false, + "name": "posts_poll_id_unique", + "entityType": "uniques", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + "actor_id", + "sharing_id" + ], + "nullsNotDistinct": false, + "name": "posts_actor_id_sharing_id_unique", + "entityType": "uniques", + "schema": "public", + "table": "posts" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "access_grants_code_unique", + "schema": "public", + "table": "access_grants", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "id" + ], + "nullsNotDistinct": false, + "name": "access_tokens_id_key", + "schema": "public", + "table": "access_tokens", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "handle" + ], + "nullsNotDistinct": false, + "name": "account_owners_handle_unique", + "schema": "public", + "table": "account_owners", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "iri" + ], + "nullsNotDistinct": false, + "name": "accounts_iri_unique", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "handle" + ], + "nullsNotDistinct": false, + "name": "accounts_handle_unique", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "client_id" + ], + "nullsNotDistinct": false, + "name": "applications_client_id_unique", + "schema": "public", + "table": "applications", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "iri" + ], + "nullsNotDistinct": false, + "name": "follows_iri_unique", + "schema": "public", + "table": "follows", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "iri" + ], + "nullsNotDistinct": false, + "name": "posts_iri_unique", + "schema": "public", + "table": "posts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "iri" + ], + "nullsNotDistinct": false, + "name": "reports_iri_unique", + "schema": "public", + "table": "reports", + "entityType": "uniques" + }, + { + "value": "jsonb_typeof(\"rsa_private_key_jwk\") = 'object'", + "name": "ck_account_owners_rsa_private_key_jwk_object", + "entityType": "checks", + "schema": "public", + "table": "account_owners" + }, + { + "value": "jsonb_typeof(\"rsa_public_key_jwk\") = 'object'", + "name": "ck_account_owners_rsa_public_key_jwk_object", + "entityType": "checks", + "schema": "public", + "table": "account_owners" + }, + { + "value": "jsonb_typeof(\"ed25519_private_key_jwk\") = 'object'", + "name": "ck_account_owners_ed25519_private_key_jwk_object", + "entityType": "checks", + "schema": "public", + "table": "account_owners" + }, + { + "value": "jsonb_typeof(\"ed25519_public_key_jwk\") = 'object'", + "name": "ck_account_owners_ed25519_public_key_jwk_object", + "entityType": "checks", + "schema": "public", + "table": "account_owners" + }, + { + "value": "json_typeof(\"fields\") = 'object'", + "name": "ck_account_owners_fields_object", + "entityType": "checks", + "schema": "public", + "table": "account_owners" + }, + { + "value": "json_typeof(\"field_htmls\") = 'object'", + "name": "ck_accounts_field_htmls_object", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "jsonb_typeof(\"emojis\") = 'object'", + "name": "ck_accounts_emojis_object", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"following_id\" != \"follower_id\"", + "name": "ck_follows_self", + "entityType": "checks", + "schema": "public", + "table": "follows" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/package.json b/package.json index 57e8b6cc..e31b2195 100644 --- a/package.json +++ b/package.json @@ -32,15 +32,17 @@ }, "dependencies": { "@aws-sdk/credential-providers": "^3.1120.0", - "@fedify/debugger": "~2.4.1", - "@fedify/fedify": "~2.4.1", - "@fedify/hono": "~2.4.1", - "@fedify/interaction-controls": "~2.4.1", + "@fedify/backfill": "~2.4.2", + "@fedify/debugger": "~2.4.2", + "@fedify/fedify": "~2.4.2", + "@fedify/hono": "~2.4.2", + "@fedify/interaction-controls": "~2.4.2", "@fedify/markdown-it-hashtag": "~0.3.0", "@fedify/markdown-it-mention": "~0.3.0", - "@fedify/postgres": "~2.4.1", - "@fedify/vocab": "~2.4.1", - "@fedify/webfinger": "~2.4.1", + "@fedify/postgres": "~2.4.2", + "@fedify/vocab": "~2.4.2", + "@fedify/vocab-runtime": "~2.4.2", + "@fedify/webfinger": "~2.4.2", "@gukhanmun/napi": "^0.2.0", "@gukhanmun/opendict-fst": "^0.2.0", "@gukhanmun/stdict-fst": "^0.2.0", @@ -92,7 +94,7 @@ }, "devDependencies": { "@dotenvx/dotenvx": "^2.32.4", - "@fedify/lint": "~2.4.1", + "@fedify/lint": "~2.4.2", "@iconify-json/lucide": "^1.2.126", "@reporters/github": "^2.1.4", "@types/fluent-ffmpeg": "^2.1.28", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5173d4d6..90435491 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,18 +15,21 @@ importers: '@aws-sdk/credential-providers': specifier: ^3.1120.0 version: 3.1120.0 + '@fedify/backfill': + specifier: ~2.4.2 + version: 2.4.2 '@fedify/debugger': - specifier: ~2.4.1 - version: 2.4.1(@fedify/fedify@2.4.1) + specifier: ~2.4.2 + version: 2.4.2(@fedify/fedify@2.4.2) '@fedify/fedify': - specifier: ~2.4.1 - version: 2.4.1 + specifier: ~2.4.2 + version: 2.4.2 '@fedify/hono': - specifier: ~2.4.1 - version: 2.4.1(@fedify/fedify@2.4.1)(hono@4.13.12) + specifier: ~2.4.2 + version: 2.4.2(@fedify/fedify@2.4.2)(hono@4.13.12) '@fedify/interaction-controls': - specifier: ~2.4.1 - version: 2.4.1(@fedify/fedify@2.4.1) + specifier: ~2.4.2 + version: 2.4.2(@fedify/fedify@2.4.2) '@fedify/markdown-it-hashtag': specifier: ~0.3.0 version: 0.3.0 @@ -34,14 +37,17 @@ importers: specifier: ~0.3.0 version: 0.3.0 '@fedify/postgres': - specifier: ~2.4.1 - version: 2.4.1(@fedify/fedify@2.4.1)(postgres@3.4.9) + specifier: ~2.4.2 + version: 2.4.2(@fedify/fedify@2.4.2)(postgres@3.4.9) '@fedify/vocab': - specifier: ~2.4.1 - version: 2.4.1 + specifier: ~2.4.2 + version: 2.4.2 + '@fedify/vocab-runtime': + specifier: ~2.4.2 + version: 2.4.2 '@fedify/webfinger': - specifier: ~2.4.1 - version: 2.4.1 + specifier: ~2.4.2 + version: 2.4.2 '@gukhanmun/napi': specifier: ^0.2.0 version: 0.2.0(@gukhanmun/types@0.2.0) @@ -191,8 +197,8 @@ importers: specifier: ^2.32.4 version: 2.32.4 '@fedify/lint': - specifier: ~2.4.1 - version: 2.4.1(@fedify/fedify@2.4.1)(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + specifier: ~2.4.2 + version: 2.4.2(@fedify/fedify@2.4.2)(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) '@iconify-json/lucide': specifier: ^1.2.126 version: 1.2.126 @@ -1183,30 +1189,33 @@ packages: '@expressive-code/plugin-text-markers@0.44.2': resolution: {integrity: sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==} - '@fedify/debugger@2.4.1': - resolution: {integrity: sha512-6y7QUaOc5c4RVVa3yn5T7EaAKXVtwrLRcs+egP0tHiRcKXGrzFBt5VJASEyq0evKJW0go3q+ePejaeYY/6Rfrw==} + '@fedify/backfill@2.4.2': + resolution: {integrity: sha512-yhUNRaozjVweA/EFSoB3rinvIwMVsP7a/cqA61U/ioVrYOIQD+xjHqk5Jwl5S7XijcQdjxvT9VnFVcfMf2DT+Q==} + + '@fedify/debugger@2.4.2': + resolution: {integrity: sha512-lZiVn+UCeaeCAJ81s7RnEo+4galXsau5dj1M7ndqA6wUnfVfwMObWsNSQhYJY4Btnptk0OWpfSjVxzgadIRC0A==} peerDependencies: - '@fedify/fedify': ^2.4.1 + '@fedify/fedify': ^2.4.2 - '@fedify/fedify@2.4.1': - resolution: {integrity: sha512-MxrEClHitiJn6RKLHrVtgY0dMHomFY4LT0/Ec7fuYMcr0aIlC9FnuiuqGn8AUqMKQaGzzdGE6RqRDfaL/jRhHA==} + '@fedify/fedify@2.4.2': + resolution: {integrity: sha512-HvzDo4OlGhvHhy1RPlfvNMQYdYPR4og8NYwN8NGCue0ZSJ6RHj4s/tuPJXqVcKJ/9sgDHstbmtQSRL3SC70t+A==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/hono@2.4.1': - resolution: {integrity: sha512-9VRfdEr8ZktdEbtp2C/z7862l2De9C3AwixJPoDCdUsI9bk/nA1WlM9KJl1F6gRQplmQtSOealhnXq3vxKVqdw==} + '@fedify/hono@2.4.2': + resolution: {integrity: sha512-eOMrWB4qv3Yn+/DQIkAiA4TitCFs+dbbsk8f5HBkx49v+wN38FHaQ91IMfRPpVJ7NtbPHeFfn4lHUHSZPvnwpA==} peerDependencies: - '@fedify/fedify': ^2.4.1 + '@fedify/fedify': ^2.4.2 hono: ^4.0.0 - '@fedify/interaction-controls@2.4.1': - resolution: {integrity: sha512-Bjygv3mIDhT7K0h80a1nMFRBZvZu1TdOoLafTcF3Gvd/vpX1msjP649V83UJlfIQx/rXXVJRrbj6e2IEx4QLPQ==} + '@fedify/interaction-controls@2.4.2': + resolution: {integrity: sha512-3Dr7SA3hHOUFekhHOwwBF/y8P+Hjf2jJZDKvVo3KpfEl6uFWjk9Eh3IYEZgP9h4w1s0xsItLUUUW1ci7ENsEfQ==} peerDependencies: - '@fedify/fedify': ^2.4.1 + '@fedify/fedify': ^2.4.2 - '@fedify/lint@2.4.1': - resolution: {integrity: sha512-W9FuRKRQDvvdY3NeZLGhHnPcuNes0opj03AgKUJE0tWURtPvLTJ1LtghLdwaTmXp5uP1S5VNV0e97O14fwtZWw==} + '@fedify/lint@2.4.2': + resolution: {integrity: sha512-5JCvFP33EmD/JWRHMO2b3cFpRYn+vFL6FsbdDDnPAygTC+TpQuI12H1SD24cCKE2BboFnb1zFIK7DXSYO7M6eg==} peerDependencies: - '@fedify/fedify': ^2.4.1 + '@fedify/fedify': ^2.4.2 eslint: '>=9.0.0' peerDependenciesMeta: eslint: @@ -1218,30 +1227,30 @@ packages: '@fedify/markdown-it-mention@0.3.0': resolution: {integrity: sha512-4uVELsxh9AW0hzC6BVFqN59YWj6M/p6AZpwb/CQDjvcinfHyQp2yYtGDV4fBgsE5WC/ukpvU1pBNH20ts8TK4w==} - '@fedify/postgres@2.4.1': - resolution: {integrity: sha512-zVjK7UHoWWWnbaA8LSXn8ppHYl+BD7bTGz+XU0JeonGtCXXgfNTjAWp3jXQXJInuU2vlW1Iw0QHqw2BB6bXReA==} + '@fedify/postgres@2.4.2': + resolution: {integrity: sha512-RRmhE45qTXgriyZGj4psCosX8Gr8WJS+ef0B8QsX1nrJbXFkly4zLvTQDwUC/dlwRaQ0D7paAXuxB4WMruLbYQ==} peerDependencies: - '@fedify/fedify': ^2.4.1 + '@fedify/fedify': ^2.4.2 postgres: ^3.4.7 - '@fedify/uri-template@2.4.1': - resolution: {integrity: sha512-x/vAXi7gWY5jx7EOPi/2Scs26Dgd8+4FX2YIi58TzCiXBOup2LvB8s8Mo7GePjP8n0B6AujGtHWij3QRAxbRcg==} + '@fedify/uri-template@2.4.2': + resolution: {integrity: sha512-USWTkTxVjEGMaL1mXEQC+mHF+sLdEU74W/Wd7LSmQLqcy5QG7TFAE2zbRQWFsSlZsGY2u+gaSFfxGbvajcRP3w==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/vocab-runtime@2.4.1': - resolution: {integrity: sha512-MB6tJMWST47nsmXon5x48EfiKxcPAmnwKFMdTwZhYnbBuQEIME5nbwnriazeQVltRSzDQjS8cpPiMWOILOChaw==} + '@fedify/vocab-runtime@2.4.2': + resolution: {integrity: sha512-cQrwrp3bptThctj+f6+QDDJ8iH70+kQZoSskERMWSksYHQpkeY2SnR7B/NN8T34SDABeDD6fO4RVi+mHHvxAOg==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/vocab-tools@2.4.1': - resolution: {integrity: sha512-LC9lirhF0WKuGnBWon0y3SLyFrWcbR/EMjDoGEYSjuzdD/NHR2uDVHe4RvjtfRvTU7ufiAG121Nk8JESkv3/Hw==} + '@fedify/vocab-tools@2.4.2': + resolution: {integrity: sha512-mxufvIJGRyyQPCNwb216jnI4CMR8RacbGMw8j3bOFJG9D12FwBI/SkUkVv8xOO+Jz9ac9+oZq2Em23KA3RcSdQ==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/vocab@2.4.1': - resolution: {integrity: sha512-s4tTVak2cJ4EjA9QobJOcz7FxSB4ikrSCnADrTRQU+fYb0f56N2Owui3Na7iZ2q+qceMeUPh2FLlcb01So+3Uw==} + '@fedify/vocab@2.4.2': + resolution: {integrity: sha512-GGvZmKa6tsV475H2gMniWrJRMM+8LKQJaBGXvywNWbejGLG1u5TkL2ioO9AVC84Ogsr4mEKNrjOQuPSchCn5Fw==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} - '@fedify/webfinger@2.4.1': - resolution: {integrity: sha512-sTCrNBnHQiR005xZL0H7+wNFghLtNq5sHmU9nKNEl8mxvQaSLoLTBLmuZIOeW57uBPc7ewlUw8vYKe/X8aW3Lw==} + '@fedify/webfinger@2.4.2': + resolution: {integrity: sha512-HqWEAxoHFWjZa0fejCN9g3g4WGNdONBugugWZRQXyOUiGTzxjQM5HKJn3QWCS06rDNLE6xdBLuD04m7CleO+EA==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} '@fxts/core@1.26.0': @@ -7729,9 +7738,13 @@ snapshots: dependencies: '@expressive-code/core': 0.44.2 - '@fedify/debugger@2.4.1(@fedify/fedify@2.4.1)': + '@fedify/backfill@2.4.2': + dependencies: + '@fedify/vocab': 2.4.2 + + '@fedify/debugger@2.4.2(@fedify/fedify@2.4.2)': dependencies: - '@fedify/fedify': 2.4.1 + '@fedify/fedify': 2.4.2 '@logtape/logtape': 2.3.10 '@opentelemetry/api': 1.9.1 '@opentelemetry/context-async-hooks': 2.11.0(@opentelemetry/api@1.9.1) @@ -7740,12 +7753,12 @@ snapshots: hono: 4.13.12 temporal-polyfill: 1.0.5 - '@fedify/fedify@2.4.1': + '@fedify/fedify@2.4.2': dependencies: - '@fedify/uri-template': 2.4.1 - '@fedify/vocab': 2.4.1 - '@fedify/vocab-runtime': 2.4.1 - '@fedify/webfinger': 2.4.1 + '@fedify/uri-template': 2.4.2 + '@fedify/vocab': 2.4.2 + '@fedify/vocab-runtime': 2.4.2 + '@fedify/webfinger': 2.4.2 '@logtape/logtape': 2.3.10 '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) @@ -7762,21 +7775,21 @@ snapshots: temporal-polyfill: 1.0.5 urlpattern-polyfill: 10.1.0 - '@fedify/hono@2.4.1(@fedify/fedify@2.4.1)(hono@4.13.12)': + '@fedify/hono@2.4.2(@fedify/fedify@2.4.2)(hono@4.13.12)': dependencies: - '@fedify/fedify': 2.4.1 + '@fedify/fedify': 2.4.2 hono: 4.13.12 - '@fedify/interaction-controls@2.4.1(@fedify/fedify@2.4.1)': + '@fedify/interaction-controls@2.4.2(@fedify/fedify@2.4.2)': dependencies: - '@fedify/fedify': 2.4.1 - '@fedify/vocab': 2.4.1 - '@fedify/vocab-runtime': 2.4.1 + '@fedify/fedify': 2.4.2 + '@fedify/vocab': 2.4.2 + '@fedify/vocab-runtime': 2.4.2 temporal-polyfill: 1.0.5 - '@fedify/lint@2.4.1(@fedify/fedify@2.4.1)(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)': + '@fedify/lint@2.4.2(@fedify/fedify@2.4.2)(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@fedify/fedify': 2.4.1 + '@fedify/fedify': 2.4.2 '@fxts/core': 1.26.0 '@typescript-eslint/parser': 8.59.0(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) '@typescript-eslint/utils': 8.59.0(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) @@ -7794,16 +7807,16 @@ snapshots: dependencies: markdown-it: 14.3.0 - '@fedify/postgres@2.4.1(@fedify/fedify@2.4.1)(postgres@3.4.9)': + '@fedify/postgres@2.4.2(@fedify/fedify@2.4.2)(postgres@3.4.9)': dependencies: - '@fedify/fedify': 2.4.1 + '@fedify/fedify': 2.4.2 '@logtape/logtape': 2.3.10 postgres: 3.4.9 temporal-polyfill: 1.0.5 - '@fedify/uri-template@2.4.1': {} + '@fedify/uri-template@2.4.2': {} - '@fedify/vocab-runtime@2.4.1': + '@fedify/vocab-runtime@2.4.2': dependencies: '@js-temporal/polyfill': 0.5.1 '@logtape/logtape': 2.3.10 @@ -7814,18 +7827,18 @@ snapshots: jsonld: 9.0.0 pkijs: 3.4.0 - '@fedify/vocab-tools@2.4.1': + '@fedify/vocab-tools@2.4.2': dependencies: '@cfworker/json-schema': 4.1.1 byte-encodings: 1.0.11 es-toolkit: 1.50.0 yaml: 2.9.0 - '@fedify/vocab@2.4.1': + '@fedify/vocab@2.4.2': dependencies: - '@fedify/vocab-runtime': 2.4.1 - '@fedify/vocab-tools': 2.4.1 - '@fedify/webfinger': 2.4.1 + '@fedify/vocab-runtime': 2.4.2 + '@fedify/vocab-tools': 2.4.2 + '@fedify/webfinger': 2.4.2 '@logtape/logtape': 2.3.10 '@multiformats/base-x': 4.0.1 '@opentelemetry/api': 1.9.1 @@ -7835,9 +7848,9 @@ snapshots: pkijs: 3.4.0 temporal-polyfill: 1.0.5 - '@fedify/webfinger@2.4.1': + '@fedify/webfinger@2.4.2': dependencies: - '@fedify/vocab-runtime': 2.4.1 + '@fedify/vocab-runtime': 2.4.2 '@logtape/logtape': 2.3.10 '@opentelemetry/api': 1.9.1 es-toolkit: 1.46.1 diff --git a/src/federation/account.ts b/src/federation/account.ts index 17a661bc..add6fb28 100644 --- a/src/federation/account.ts +++ b/src/federation/account.ts @@ -12,6 +12,7 @@ import { isActor, Link, lookupObject, + type Object as APObject, PropertyValue, Reject, Undo, @@ -91,6 +92,9 @@ export class AccountHandleConflictError extends Error { export type PersistAccountOptions = { contextLoader?: DocumentLoader; documentLoader?: DocumentLoader; + /** A bounded, HTTP-only lookup used by historical scraping. */ + objectLoader?: (iri: URL) => Promise; + accountAncestry?: ReadonlySet; skipUpdate?: boolean; handleConflictPolicy?: PersistAccountHandleConflictPolicy; mediaProxyMode?: MediaProxyMode; @@ -240,7 +244,6 @@ export async function persistAccount( baseUrl: string | URL, options: PersistAccountOptions = {}, ): Promise<(schema.Account & { owner: schema.AccountOwner | null }) | null> { - const opts = { ...options, suppressError: true }; if ( actor.id == null || actor.inboxId == null || @@ -255,6 +258,16 @@ export async function persistAccount( }); if (options.skipUpdate && existingAccount != null) return existingAccount; if (existingAccount?.owner != null) return existingAccount; + if (options.accountAncestry?.has(actorId.href)) + return existingAccount ?? null; + options = { + ...options, + accountAncestry: new Set([ + ...(options.accountAncestry ?? []), + actorId.href, + ]), + }; + const opts = { ...options, suppressError: true }; let handle: string; try { handle = await getActorHandle(actor); @@ -497,7 +510,10 @@ export async function persistAccountByIri( where: { iri: { eq: iri } }, }); if (account != null) return account; - const actor = await lookupObject(iri, options); + const actor = + options.objectLoader == null + ? await lookupObject(iri, options) + : await options.objectLoader(new URL(iri)); if (!isActor(actor) || actor.id == null) return null; return await persistAccount(db, actor, baseUrl, options); } diff --git a/src/federation/post.ts b/src/federation/post.ts index dfdcea65..2700f443 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -226,6 +226,11 @@ async function getVerifiedQuoteAuthorizationIri( return verification.verified ? verification.authorizationIri : null; } +type PersistedPost = Post & { + account: Account & { owner: AccountOwner | null }; + mentions: Mention[]; +}; + export async function persistPost( db: DatabaseLike, object: ASPost, @@ -234,7 +239,12 @@ export async function persistPost( account?: Account & { owner: AccountOwner | null }; enqueueRemoteReplies?: boolean; fetchEmojiReactions?: boolean; + fetchReplyTarget?: boolean; + postAncestry?: ReadonlySet; replyTarget?: Post; + persistPrepared?: ( + write: (db: DatabaseLike) => Promise, + ) => Promise; } = {}, ): Promise< | (Post & { @@ -252,6 +262,11 @@ export async function persistPost( if (existingPost != null && existingPost.account.owner != null) { return existingPost; } + if (options.postAncestry?.has(object.id.href)) return existingPost ?? null; + options = { + ...options, + postAncestry: new Set([...(options.postAncestry ?? []), object.id.href]), + }; const publishedRaw = toDate(object.published); const updatedRaw = toDate(object.updated); const now = Date.now(); @@ -289,16 +304,17 @@ export async function persistPost( replyTargetId = options.replyTarget.id; } else { const result = await db - .select({ id: posts.id }) + .select() .from(posts) .where(eq(posts.iri, object.replyTargetId.href)) .limit(1); if (result != null && result.length > 0) { replyTargetId = result[0].id; + replyTargetObj = result[0]; logger.debug("The reply target is already persisted: {replyTargetId}", { replyTargetId, }); - } else { + } else if (options.fetchReplyTarget !== false) { logger.debug("Persisting the reply target..."); const replyTarget = await object.getReplyTarget(options); if (isPost(replyTarget)) { @@ -389,7 +405,10 @@ export async function persistPost( }); } else { logger.debug("Persisting the quote target..."); - const quoteTarget = await lookupObject(objectLink, options); + const quoteTarget = + options.objectLoader == null + ? await lookupObject(objectLink, options) + : await options.objectLoader(objectLink); if (isPost(quoteTarget)) { const quoteTargetObj = await persistPost(db, quoteTarget, baseUrl, { ...options, @@ -569,7 +588,7 @@ export async function persistPost( .where(eq(posts.iri, postIri)) .for("update") )[0]; - await database + const inserted = await database .insert(posts) .values({ ...values, @@ -592,8 +611,10 @@ export async function persistPost( WHEN ${storedAccepted} THEN ${posts.quoteAuthorizationIri} ELSE ${impoliteAuthorization} END`, }, - setWhere: eq(posts.iri, postIri), - }); + setWhere: options.skipUpdate ? sql`false` : eq(posts.iri, postIri), + }) + .returning({ id: posts.id }); + if (options.skipUpdate && inserted.length === 0) return undefined; const post = await database.query.posts.findFirst({ where: { iri: { eq: postIri } }, }); @@ -625,135 +646,47 @@ export async function persistPost( } return post; }; - let post = - localQuoteTarget == null - ? await persistValues(db) - : await db.transaction(persistValues); - if (post == null) return null; - if ( - options.fetchEmojiReactions !== false && - account.owner == null && - (object instanceof Note || - object instanceof Question || - object instanceof Article) - ) { - const emojiReactions = await object.getEmojiReactions({ - ...options, - crossOrigin: "trust", - suppressError: true, - }); - if (emojiReactions != null) { - await persistRemoteEmojiReactions( - db, - emojiReactions, - { - ...post, - account, - }, - baseUrl, - options, - ); - } + // Ordinary inbox callers retain the post and quote notification if later + // media transport fails. Only scrape callers require complete atomic items. + const earlyPost = + options.persistPrepared == null + ? localQuoteTarget == null + ? await persistValues(db) + : await db.transaction(persistValues) + : undefined; + if (options.persistPrepared == null && earlyPost == null) { + if (!options.skipUpdate) return null; + return ( + (await db.query.posts.findFirst({ + where: { iri: { eq: postIri } }, + with: { account: { with: { owner: true } }, mentions: true }, + })) ?? null + ); } - let rescheduledPollId: Uuid | undefined; + + // Resolve poll options and attachment transport before the write phase. + // Scrape workers can then fence and commit complete items without holding + // database locks while waiting for a remote server or a pacing slot. + const choices: [string, number][] = []; + let multiple = false; if (object instanceof Question) { - const options: [string, number][] = []; - let multiple = false; - for await (const option of object.getExclusiveOptions()) { + for await (const option of object.getExclusiveOptions(options)) { if (option instanceof Note && option.name != null) { - const replies = await option.getReplies(); - options.push([option.name.toString(), replies?.totalItems ?? 0]); + const replies = await option.getReplies(options); + choices.push([option.name.toString(), replies?.totalItems ?? 0]); } } - if (options.length < 1) { - for await (const option of object.getInclusiveOptions()) { + if (choices.length < 1) { + for await (const option of object.getInclusiveOptions(options)) { if (option instanceof Note && option.name != null) { - const replies = await option.getReplies(); - options.push([option.name.toString(), replies?.totalItems ?? 0]); + const replies = await option.getReplies(options); + choices.push([option.name.toString(), replies?.totalItems ?? 0]); } multiple = true; } } - if (options.length > 0 && object.endTime != null) { - if (post.pollId == null) { - const [poll] = await db - .insert(polls) - .values({ - id: uuidv7(), - multiple, - votersCount: object.voters ?? 0, - expires: toDate(object.endTime), - }) - .returning(); - await db.insert(pollOptions).values( - options.map(([title, votesCount], index) => ({ - pollId: poll.id, - index, - title, - votesCount, - })), - ); - await db - .update(posts) - .set({ pollId: poll.id }) - .where(eq(posts.id, post.id)); - } else { - const previousPoll = await db.query.polls.findFirst({ - where: { id: { eq: post.pollId } }, - }); - const [poll] = await db - .update(polls) - .set({ - multiple, - votersCount: object.voters ?? 0, - expires: toDate(object.endTime), - }) - .where(eq(polls.id, post.pollId)) - .returning(); - if (previousPoll == null || +previousPoll.expires !== +poll.expires) { - rescheduledPollId = poll.id; - } - for (let index = 0; index < options.length; index++) { - const [title, votesCount] = options[index]; - await db - .insert(pollOptions) - .values({ pollId: poll.id, index, title, votesCount }) - .onConflictDoUpdate({ - target: [pollOptions.pollId, pollOptions.index], - set: { title, votesCount }, - setWhere: and( - eq(pollOptions.pollId, poll.id), - eq(pollOptions.index, index), - ), - }); - } - await db - .delete(pollOptions) - .where( - and( - eq(pollOptions.pollId, post.pollId), - gte(pollOptions.index, options.length), - ), - ); - } - } - } - const mentionRows: Mention[] = []; - await db.delete(mentions).where(eq(mentions.postId, post.id)); - for (const account of mentionedAccounts.values()) { - const result = await db - .insert(mentions) - .values({ - accountId: account.id, - postId: post.id, - }) - .onConflictDoNothing({ - target: [mentions.accountId, mentions.postId], - }) - .returning(); - mentionRows.push(...result); } - await db.delete(media).where(eq(media.postId, post.id)); + const preparedMedia: Omit[] = []; let mediaPosition = 0; for await (const attachment of object.getAttachments(options)) { if ( @@ -853,9 +786,8 @@ export async function persistPost( thumbnailHeight: metadata.height!, }; } - await db.insert(media).values({ + preparedMedia.push({ id, - postId: post.id, position: mediaPosition, type: mediaType, url, @@ -864,35 +796,168 @@ export async function persistPost( width: attachment.width ?? metadata.width!, height: attachment.height ?? metadata.height!, ...thumbnail, - } satisfies NewMedium); + } satisfies Omit); mediaPosition++; } - post = await db.query.posts.findFirst({ - where: { iri: { eq: object.id.href } }, - with: { account: true, media: true }, - }); - if (post == null) return null; - if ( - options.enqueueRemoteReplies !== false && - account.owner == null && - repliesIri != null - ) { - await enqueueRemoteReplyScrape(db, { - baseUrl, - post, - repliesIri, + + const writePrepared = async ( + db: DatabaseLike, + ): Promise => { + let post = + earlyPost ?? + (localQuoteTarget == null + ? await persistValues(db) + : await db.transaction(persistValues)); + if (post == null) { + if (!options.skipUpdate) return null; + return ( + (await db.query.posts.findFirst({ + where: { iri: { eq: postIri } }, + with: { account: { with: { owner: true } }, mentions: true }, + })) ?? null + ); + } + if ( + options.fetchEmojiReactions !== false && + account.owner == null && + (object instanceof Note || + object instanceof Question || + object instanceof Article) + ) { + const emojiReactions = await object.getEmojiReactions({ + ...options, + crossOrigin: "trust", + suppressError: true, + }); + if (emojiReactions != null) { + await persistRemoteEmojiReactions( + db, + emojiReactions, + { + ...post, + account, + }, + baseUrl, + options, + ); + } + } + let rescheduledPollId: Uuid | undefined; + if (object instanceof Question) { + if (choices.length > 0 && object.endTime != null) { + if (post.pollId == null) { + const [poll] = await db + .insert(polls) + .values({ + id: uuidv7(), + multiple, + votersCount: object.voters ?? 0, + expires: toDate(object.endTime), + }) + .returning(); + await db.insert(pollOptions).values( + choices.map(([title, votesCount], index) => ({ + pollId: poll.id, + index, + title, + votesCount, + })), + ); + await db + .update(posts) + .set({ pollId: poll.id }) + .where(eq(posts.id, post.id)); + } else { + const previousPoll = await db.query.polls.findFirst({ + where: { id: { eq: post.pollId } }, + }); + const [poll] = await db + .update(polls) + .set({ + multiple, + votersCount: object.voters ?? 0, + expires: toDate(object.endTime), + }) + .where(eq(polls.id, post.pollId)) + .returning(); + if (previousPoll == null || +previousPoll.expires !== +poll.expires) { + rescheduledPollId = poll.id; + } + for (let index = 0; index < choices.length; index++) { + const [title, votesCount] = choices[index]; + await db + .insert(pollOptions) + .values({ pollId: poll.id, index, title, votesCount }) + .onConflictDoUpdate({ + target: [pollOptions.pollId, pollOptions.index], + set: { title, votesCount }, + setWhere: and( + eq(pollOptions.pollId, poll.id), + eq(pollOptions.index, index), + ), + }); + } + await db + .delete(pollOptions) + .where( + and( + eq(pollOptions.pollId, post.pollId), + gte(pollOptions.index, choices.length), + ), + ); + } + } + } + const mentionRows: Mention[] = []; + await db.delete(mentions).where(eq(mentions.postId, post.id)); + for (const account of mentionedAccounts.values()) { + const result = await db + .insert(mentions) + .values({ + accountId: account.id, + postId: post.id, + }) + .onConflictDoNothing({ + target: [mentions.accountId, mentions.postId], + }) + .returning(); + mentionRows.push(...result); + } + await db.delete(media).where(eq(media.postId, post.id)); + for (const medium of preparedMedia) { + await db.insert(media).values({ ...medium, postId: post.id }); + } + post = await db.query.posts.findFirst({ + where: { iri: { eq: postIri } }, + with: { account: true, media: true }, }); - } - await appendPostToTimelines(db, { - ...post, - sharing: null, - mentions: mentionRows, - replyTarget: replyTargetObj, - }); - if (rescheduledPollId != null) { - await enqueuePollNotification(db, rescheduledPollId, baseUrl); - } - return { ...post, account, mentions: mentionRows }; + if (post == null) return null; + if ( + options.enqueueRemoteReplies !== false && + account.owner == null && + (repliesIri != null || object.contextIds[0] != null) + ) { + await enqueueRemoteReplyScrape(db, { + baseUrl, + post, + repliesIri, + contextIri: object.contextIds[0], + }); + } + await appendPostToTimelines(db, { + ...post, + sharing: null, + mentions: mentionRows, + replyTarget: replyTargetObj, + }); + if (rescheduledPollId != null) { + await enqueuePollNotification(db, rescheduledPollId, baseUrl); + } + return { ...post, account, mentions: mentionRows }; + }; + return options.persistPrepared == null + ? await writePrepared(db) + : await options.persistPrepared(writePrepared); } export async function persistSharingPost( diff --git a/src/federation/replies-context.ts b/src/federation/replies-context.ts new file mode 100644 index 00000000..d2782c0b --- /dev/null +++ b/src/federation/replies-context.ts @@ -0,0 +1,160 @@ +import { and, eq, inArray } from "drizzle-orm"; + +import db from "../db"; +import { + type RemoteReplyScrapeJob, + remoteContextScrapeAliases as aliases, + remoteReplyScrapeJobs as jobs, + remoteReplyScrapeOrigins as origins, +} from "../schema"; +import { + isActiveOrCoolingDown, + laterBySeconds, + lockContextScrapeIdentity, + REMOTE_REPLIES_SCRAPE_COOLDOWN_SECONDS, + settleContextReplies, +} from "./replies"; +import { lockScrapeAttempt } from "./replies-state"; + +/** Coalesce root identities without discarding any seed's gated replies. */ +export async function canonicalizeContextJob( + job: RemoteReplyScrapeJob, + canonical: URL, + finalUrl: URL, + now: Date, +): Promise { + return await db.transaction(async (tx) => { + await lockContextScrapeIdentity(tx); + const alias = await tx.query.remoteContextScrapeAliases.findFirst({ + where: { iri: { eq: canonical.href } }, + }); + const [candidate] = await tx + .select() + .from(jobs) + .where( + alias == null + ? and(eq(jobs.kind, "context"), eq(jobs.repliesIri, canonical.href)) + : eq(jobs.id, alias.jobId), + ); + // Context blockers always precede replies gates. Deterministic ordering + // between context rows avoids two simultaneous alias merges deadlocking. + const ids = [ + ...new Set([job.id, ...(candidate == null ? [] : [candidate.id])]), + ].sort(); + const locked = await tx + .select() + .from(jobs) + .where(inArray(jobs.id, ids)) + .orderBy(jobs.id) + .for("update"); + await lockScrapeAttempt(tx, job); + let winner = locked.find((row) => row.id !== job.id); + if ( + winner != null && + !isActiveOrCoolingDown( + winner, + laterBySeconds(-REMOTE_REPLIES_SCRAPE_COOLDOWN_SECONDS, now), + ) + ) { + await tx + .update(aliases) + .set({ jobId: job.id }) + .where(eq(aliases.jobId, winner.id)); + await tx + .update(jobs) + .set({ blockedByJobId: job.id }) + .where( + and(eq(jobs.status, "waiting"), eq(jobs.blockedByJobId, winner.id)), + ); + await tx.delete(jobs).where(eq(jobs.id, winner.id)); + winner = undefined; + } + if (winner != null) { + await tx + .update(aliases) + .set({ jobId: winner.id }) + .where(eq(aliases.jobId, job.id)); + await tx + .update(jobs) + .set({ blockedByJobId: winner.id }) + .where( + and(eq(jobs.status, "waiting"), eq(jobs.blockedByJobId, job.id)), + ); + await settleContextReplies(tx, winner, now); + for (const iri of new Set([ + job.repliesIri, + finalUrl.href, + canonical.href, + ])) { + await tx + .insert(aliases) + .values({ iri, jobId: winner.id }) + .onConflictDoUpdate({ + target: aliases.iri, + set: { jobId: winner.id }, + }); + } + await tx + .update(origins) + .set({ processingJobId: null, processingStartedAt: null }) + .where( + and( + eq(origins.originHost, job.originHost), + eq(origins.processingJobId, job.id), + ), + ); + await tx.delete(jobs).where(eq(jobs.id, job.id)); + return null; + } + const hostChanged = canonical.host !== job.originHost; + if (hostChanged && job.hostRequeues >= 1) + throw new Error( + "Context collection repeatedly changes its canonical host", + ); + await tx + .insert(origins) + .values({ originHost: canonical.host }) + .onConflictDoNothing(); + const [promoted] = await tx + .update(jobs) + .set({ + repliesIri: canonical.href, + ...(hostChanged + ? { + originHost: canonical.host, + hostRequeues: job.hostRequeues + 1, + status: "pending" as const, + startedAt: null, + nextAttemptAt: now, + nextDispatchAt: now, + } + : {}), + updated: now, + }) + .where(eq(jobs.id, job.id)) + .returning(); + for (const iri of new Set([ + job.repliesIri, + finalUrl.href, + canonical.href, + ])) { + await tx + .insert(aliases) + .values({ iri, jobId: job.id }) + .onConflictDoUpdate({ target: aliases.iri, set: { jobId: job.id } }); + } + if (hostChanged) { + await tx + .update(origins) + .set({ processingJobId: null, processingStartedAt: null }) + .where( + and( + eq(origins.originHost, job.originHost), + eq(origins.processingJobId, job.id), + ), + ); + return null; + } + return promoted; + }); +} diff --git a/src/federation/replies-loader.ts b/src/federation/replies-loader.ts new file mode 100644 index 00000000..df9620af --- /dev/null +++ b/src/federation/replies-loader.ts @@ -0,0 +1,311 @@ +import { + type DocumentLoader, + Object as APObject, + type RemoteDocument, +} from "@fedify/vocab"; +import { preloadedContexts } from "@fedify/vocab-runtime"; +import { eq, sql } from "drizzle-orm"; + +import db from "../db"; +import { + type RemoteReplyScrapeJob, + remoteReplyScrapeOrigins as origins, +} from "../schema"; +import { isHttpIri, laterBySeconds } from "./replies"; +import { LostScrapeAttempt } from "./replies-state"; + +export class ScrapeRequestLimit extends Error {} +export class ScrapeDeferred extends Error { + constructor( + readonly retryAt: Date, + readonly host: string, + readonly cause?: unknown, + ) { + super(`Requests to ${host} deferred until ${retryAt.toISOString()}`); + } +} + +export function getErrorStatus(error: unknown): number | null { + return error != null && + typeof error === "object" && + "response" in error && + error.response instanceof Response + ? error.response.status + : null; +} + +export function retryAfterSeconds( + error: unknown, + now = new Date(), +): number | null { + if ( + error == null || + typeof error !== "object" || + !("response" in error) || + !(error.response instanceof Response) + ) + return null; + const value = error.response.headers.get("Retry-After")?.trim(); + if (value == null) return null; + if (/^-?\d+$/.test(value)) { + const seconds = Number.parseInt(value, 10); + return seconds >= 0 ? seconds : null; + } + const date = Date.parse(value); + return Number.isNaN(date) + ? null + : Math.max(0, Math.ceil((date - now.getTime()) / 1000)); +} + +/** One invocation budget and cache shared by traversal and persistence. */ +export function createScrapeLoader({ + getJob, + documentLoader, + contextLoader, + maxRequests, + intervalSeconds, + backoffSeconds, + clock, + checkpoint, + sleep, + signal: shutdown, +}: { + getJob: () => RemoteReplyScrapeJob; + documentLoader: DocumentLoader; + contextLoader: DocumentLoader; + maxRequests: number; + intervalSeconds: number; + backoffSeconds: number; + clock: () => Date; + checkpoint: () => Promise; + sleep: (milliseconds: number) => Promise; + signal?: AbortSignal; +}) { + const controller = new AbortController(); + const signal = + shutdown == null + ? controller.signal + : AbortSignal.any([shutdown, controller.signal]); + const documents = new Map>(); + const objects = new Map>(); + const failures = new Map(); + const blockedHosts = new Map(); + let requests = 0; + let partial = false; + const check = () => signal.throwIfAborted(); + const stop = (error: unknown): never => { + controller.abort(error); + throw error; + }; + const record = (url: string, error: unknown) => { + failures.set(url, error); + partial = true; + }; + + async function wait(milliseconds: number) { + let remaining = milliseconds; + while (remaining > 0) { + check(); + await checkpoint(); + const chunk = Math.min(remaining, 1000); + await sleep(chunk); + remaining -= chunk; + } + check(); + } + + async function reserve(host: string) { + const now = clock(); + // Normal configured pacing must make progress across an attempt. + // Only waits beyond one configured interval (or 60s) need deferral. + const deadline = laterBySeconds(Math.max(60, intervalSeconds), now); + const [slot] = await db + .insert(origins) + .values({ + originHost: host, + nextRequestAt: laterBySeconds(intervalSeconds, now), + updated: now, + }) + .onConflictDoUpdate({ + target: origins.originHost, + set: { + nextRequestAt: sql`greatest(${origins.nextRequestAt}, ${origins.cooldownUntil}, ${now.toISOString()}::timestamptz) + ${intervalSeconds} * interval '1 second'`, + updated: sql`greatest(${origins.updated}, ${now.toISOString()}::timestamptz)`, + }, + setWhere: sql`greatest(${origins.nextRequestAt}, ${origins.cooldownUntil}, ${now.toISOString()}::timestamptz) <= ${deadline.toISOString()}::timestamptz`, + }) + .returning({ next: origins.nextRequestAt }); + if (slot == null) { + const origin = await db.query.remoteReplyScrapeOrigins.findFirst({ + where: { originHost: { eq: host } }, + }); + const retryAt = + origin == null + ? deadline + : new Date(Math.max(+origin.nextRequestAt, +origin.cooldownUntil)); + const error = new ScrapeDeferred(retryAt, host); + if (host === getJob().originHost) stop(error); + blockedHosts.set(host, retryAt); + throw error; + } + const slotTime = +slot.next - intervalSeconds * 1000; + await wait(Math.max(0, slotTime - +now)); + await checkpoint(); + check(); + const origin = await db.query.remoteReplyScrapeOrigins.findFirst({ + where: { originHost: { eq: host } }, + }); + // Later slot reservations are not cooldowns. An intervening 429 is. + if ( + origin != null && + +origin.cooldownUntil > Math.max(slotTime, +clock()) + ) { + const error = new ScrapeDeferred(origin.cooldownUntil, host); + if (host === getJob().originHost) stop(error); + blockedHosts.set(host, origin.cooldownUntil); + throw error; + } + } + + const rawLoader: DocumentLoader = (url, options) => { + check(); + const cached = documents.get(url); + if (cached != null) return cached; + const promise = (async () => { + const iri = new URL(url); + if (!isHttpIri(iri)) throw new Error(`Unsupported document IRI: ${url}`); + const cooldown = blockedHosts.get(iri.host); + if (cooldown != null) throw new ScrapeDeferred(cooldown, iri.host); + if (requests >= maxRequests) + stop(new ScrapeRequestLimit("Scrape document request limit reached")); + await reserve(iri.host); + check(); + if (requests >= maxRequests) + stop(new ScrapeRequestLimit("Scrape document request limit reached")); + requests++; + try { + return await documentLoader(url, { ...options, signal }); + } catch (error) { + if (getErrorStatus(error) === 429) { + const now = clock(); + const retryAt = laterBySeconds( + retryAfterSeconds(error, now) ?? backoffSeconds, + now, + ); + blockedHosts.set(iri.host, retryAt); + await db + .update(origins) + .set({ + cooldownUntil: sql`greatest(${origins.cooldownUntil}, ${retryAt.toISOString()}::timestamptz)`, + nextRequestAt: sql`greatest(${origins.nextRequestAt}, ${retryAt.toISOString()}::timestamptz)`, + updated: sql`greatest(${origins.updated}, ${now.toISOString()}::timestamptz)`, + }) + .where(eq(origins.originHost, iri.host)); + if (iri.host === getJob().originHost) + stop(new ScrapeDeferred(retryAt, iri.host, error)); + } + throw error; + } finally { + const now = clock(); + await db + .update(origins) + .set({ + lastRequestAt: now, + updated: sql`greatest(${origins.updated}, ${now.toISOString()}::timestamptz)`, + }) + .where(eq(origins.originHost, iri.host)); + } + })().catch((error: unknown) => { + record(url, error); + if (error instanceof LostScrapeAttempt) stop(error); + throw error; + }); + documents.set(url, promise); + return promise; + }; + + const boundedContextLoader: DocumentLoader = (url, options) => { + check(); + if (Object.hasOwn(preloadedContexts, url)) + return contextLoader(url, options); + return rawLoader(url, options); + }; + + const loadObject = (iri: URL): Promise => { + check(); + const cached = objects.get(iri.href); + if (cached != null) return cached; + const promise = (async () => { + const document = await rawLoader(iri.href); + check(); + const object = await APObject.fromJsonLd(document.document, { + documentLoader: rawLoader, + contextLoader: boundedContextLoader, + baseUrl: new URL(document.documentUrl), + }); + check(); + if ( + object.id != null && + object.id.origin !== new URL(document.documentUrl).origin + ) + throw new Error(`Document claims a foreign-origin ID: ${iri.href}`); + return object; + })().catch((error: unknown) => { + record(iri.href, error); + throw error; + }); + objects.set(iri.href, promise); + return promise; + }; + const lookup = async (iri: URL) => { + try { + return await loadObject(iri); + } catch { + check(); + return null; + } + }; + return { + signal, + check, + loadObject, + lookup, + documentLoader: rawLoader, + contextLoader: boundedContextLoader, + get requestCount() { + return requests; + }, + get skippedItems() { + return failures.size; + }, + get partial() { + return partial; + }, + get errorMessage() { + if (failures.size === 0) return null; + return [...failures] + .slice(0, 10) + .map(([iri, error]) => { + const cause = + error instanceof ScrapeDeferred ? (error.cause ?? error) : error; + const retryAfter = + cause != null && + typeof cause === "object" && + "response" in cause && + cause.response instanceof Response + ? cause.response.headers.get("Retry-After") + : null; + return `${iri}: ${cause instanceof Error ? cause.message : String(cause)} (HTTP ${getErrorStatus(cause) ?? "unknown"}${retryAfter == null ? "" : `, Retry-After: ${retryAfter}`})`; + }) + .join("; "); + }, + async documentUrl(iri: URL) { + return new URL((await rawLoader(iri.href)).documentUrl); + }, + cacheObject(object: APObject, iris: URL[]) { + for (const iri of iris) objects.set(iri.href, Promise.resolve(object)); + }, + }; +} + +export type ScrapeLoader = ReturnType; diff --git a/src/federation/replies-state.ts b/src/federation/replies-state.ts new file mode 100644 index 00000000..56b574d3 --- /dev/null +++ b/src/federation/replies-state.ts @@ -0,0 +1,46 @@ +import { and, eq, isNull } from "drizzle-orm"; + +import type { Transaction } from "../db"; +import { + type RemoteReplyScrapeJob, + remoteReplyScrapeJobs, + remoteReplyScrapeOrigins, +} from "../schema"; + +export class LostScrapeAttempt extends Error {} + +export function processingJobAttemptCondition(job: RemoteReplyScrapeJob) { + return and( + eq(remoteReplyScrapeJobs.id, job.id), + eq(remoteReplyScrapeJobs.status, "processing"), + eq(remoteReplyScrapeJobs.attempts, job.attempts), + job.startedAt == null + ? isNull(remoteReplyScrapeJobs.startedAt) + : eq(remoteReplyScrapeJobs.startedAt, job.startedAt), + ); +} + +/** Take the commit fence after remote I/O, with no root-pool awaits afterward. */ +export async function lockScrapeAttempt( + tx: Transaction, + job: RemoteReplyScrapeJob, +) { + const [owned] = await tx + .select({ id: remoteReplyScrapeJobs.id }) + .from(remoteReplyScrapeJobs) + .where(processingJobAttemptCondition(job)) + .for("update"); + if (owned == null) throw new LostScrapeAttempt("Scrape attempt was replaced"); + const [origin] = await tx + .select({ host: remoteReplyScrapeOrigins.originHost }) + .from(remoteReplyScrapeOrigins) + .where( + and( + eq(remoteReplyScrapeOrigins.originHost, job.originHost), + eq(remoteReplyScrapeOrigins.processingJobId, job.id), + ), + ) + .for("update"); + if (origin == null) + throw new LostScrapeAttempt("Scrape origin lease was replaced"); +} diff --git a/src/federation/replies-tasks.ts b/src/federation/replies-tasks.ts index 7f3bc30f..c5e8cdc5 100644 --- a/src/federation/replies-tasks.ts +++ b/src/federation/replies-tasks.ts @@ -9,7 +9,7 @@ import { remoteReplyScrapeOrigins as origins, } from "../schema"; import { uuid, type Uuid } from "../uuid"; -import { REMOTE_REPLIES_SCRAPE_DEPTH } from "./replies"; +import { REMOTE_REPLIES_SCRAPE_DEPTH, settleContextReplies } from "./replies"; import type { ProcessRemoteReplyScrapeJobsOptions } from "./replies-worker"; const logger = getLogger(["hollo", "federation", "replies-worker"]); @@ -221,6 +221,32 @@ export function registerRemoteReplyScrapes( const stale = new Date( now.getTime() - (options.staleProcessingSeconds ?? STALE_SECONDS) * 1000, ); + // Lock blockers before waiting replies, matching enqueue and completion. + await db.transaction(async (tx) => { + const blockers = await tx + .select() + .from(jobs) + .where( + and( + eq(jobs.kind, "context"), + sql`${jobs.status} in ('completed', 'failed')`, + sql`exists (select 1 from ${jobs} gate where gate.blocked_by_job_id = ${jobs.id} and gate.status = 'waiting')`, + ), + ) + .limit(RECOVERY_BATCH) + .for("update", { skipLocked: true }); + for (const blocker of blockers) + await settleContextReplies(tx, blocker, now); + await tx + .update(jobs) + .set({ + status: "pending", + blockedByJobId: null, + nextDispatchAt: now, + updated: now, + }) + .where(and(eq(jobs.status, "waiting"), isNull(jobs.blockedByJobId))); + }); await db.transaction(async (tx) => { const batch = await tx .select() diff --git a/src/federation/replies-worker.test.ts b/src/federation/replies-worker.test.ts index a9bdc7c0..f4558651 100644 --- a/src/federation/replies-worker.test.ts +++ b/src/federation/replies-worker.test.ts @@ -5,15 +5,25 @@ import { type MessageQueue, type MessageQueueEnqueueOptions, } from "@fedify/fedify"; -import type { RemoteDocument } from "@fedify/vocab"; -import { and, eq, inArray, isNull, lte } from "drizzle-orm"; +import { Note, Person, type RemoteDocument } from "@fedify/vocab"; +import { and, eq, inArray, isNull, lte, sql } from "drizzle-orm"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { cleanDatabase } from "../../tests/helpers"; -import { createAccount } from "../../tests/helpers/oauth"; +import { + createAccount, + createOAuthApplication, + getAccessToken, + bearerAuthorization, +} from "../../tests/helpers/oauth"; import db from "../db"; import { accounts, + follows, + lists, + listMembers, + blocks, + mutes, instances, posts, remoteReplyScrapeJobs, @@ -21,6 +31,7 @@ import { } from "../schema"; import type { Uuid } from "../uuid"; import { uuidv7 } from "../uuid"; +import { enqueueRemoteReplyScrape } from "./replies"; import { registerRemoteReplyScrapes } from "./replies-tasks"; import { claimRemoteReplyScrapeJob as claimById, @@ -515,58 +526,38 @@ describe("remote replies scrape worker", () => { expect(job?.attempts).toBe(1); }); - it("does not reclaim processing jobs during long throttling sleeps", async () => { - expect.assertions(4); + it("reschedules long cooldowns without sleeping or advancing slots", async () => { const { jobId, postIri, repliesIri } = await seedPostWithScrapeJob(); + const now = new Date("2026-04-25T00:00:00Z"); + const sleep = vi.fn(async () => undefined); await seedRemoteAccount("replyer"); - const startedAt = new Date("2026-04-25T00:00:00.000Z"); - const requestTimes = [ - startedAt, - new Date("2026-04-25T00:00:01.000Z"), - new Date("2026-04-25T00:07:31.000Z"), - new Date("2026-04-25T00:15:01.000Z"), - new Date("2026-04-25T00:20:02.000Z"), - new Date("2026-04-25T00:20:03.000Z"), - ]; - const reclaimTimes = [ - new Date("2026-04-25T00:07:31.000Z"), - new Date("2026-04-25T00:16:00.000Z"), - new Date("2026-04-25T00:20:01.000Z"), - ]; - let reclaimedDuringSleep = false; - const sleepMilliseconds: number[] = []; - - const processed = await processDueRemoteReplyScrapeJobs({ - clock: () => requestTimes.shift() ?? new Date("2026-04-25T00:20:04.000Z"), - documentLoader: makeLoader({ - [repliesIri]: collection(repliesIri, [ - reply({ - id: "https://remote.test/@replyer/posts/1", - replyTarget: postIri, - }), - ]), - "https://remote.test/@replyer": actor("replyer"), - }), - intervalSeconds: 20 * 60, - now: startedAt, - sleep: async (milliseconds) => { - sleepMilliseconds.push(milliseconds); - const reclaimed = await claimRemoteReplyScrapeJob( - reclaimTimes.shift() ?? new Date("2026-04-25T00:20:01.000Z"), - 15 * 60, - ); - reclaimedDuringSleep = reclaimed != null; + await processDueRemoteReplyScrapeJobs({ + now, + intervalSeconds: 120, + sleep, + documentLoader: async (url) => { + if (url === repliesIri) + await db + .update(remoteReplyScrapeOrigins) + .set({ cooldownUntil: new Date(+now + 1200_000) }) + .where(eq(remoteReplyScrapeOrigins.originHost, "remote.test")); + return makeLoader({ + [repliesIri]: collection(repliesIri, [ + reply({ id: "https://remote.test/posts/1", replyTarget: postIri }), + ]), + "https://remote.test/@replyer": actor("replyer"), + })(url); }, - staleProcessingSeconds: 15 * 60, }); - const job = await db.query.remoteReplyScrapeJobs.findFirst({ where: { id: { eq: jobId } }, }); - expect(processed).toBe(1); - expect(reclaimedDuringSleep).toBe(false); - expect(sleepMilliseconds).toEqual([450_000, 450_000, 300_000]); - expect(job?.attempts).toBe(1); + const origin = await db.query.remoteReplyScrapeOrigins.findFirst(); + expect(job?.status).toBe("pending"); + expect(job?.nextAttemptAt).toEqual(new Date(+now + 1200_000)); + expect(origin?.nextRequestAt).toEqual(new Date(+now + 1200_000)); + expect(origin?.processingJobId).toBeNull(); + expect(sleep).not.toHaveBeenCalled(); }); it("does not reclaim jobs during long remote fetches", async () => { @@ -851,7 +842,9 @@ describe("remote replies scrape worker", () => { const job = await db.query.remoteReplyScrapeJobs.findFirst({ where: { id: { eq: jobId } }, }); - const origin = await db.query.remoteReplyScrapeOrigins.findFirst(); + const origin = await db.query.remoteReplyScrapeOrigins.findFirst({ + where: { originHost: { eq: "remote.test" } }, + }); expect(processed).toBe(0); expect(job?.status).toBe("failed"); expect(job?.errorMessage).toBe("cross-origin rate limited"); @@ -1211,46 +1204,34 @@ describe("remote replies scrape worker", () => { expect(origin?.processingJobId).toBe(next.jobId); }); - it("records per-request timestamps for throttled origin request fields", async () => { - expect.assertions(3); + it("spaces actual document requests and records their completion times", async () => { const { postIri, repliesIri } = await seedPostWithScrapeJob(); - const now = new Date("2026-04-25T00:00:00.000Z"); - const requestTimes = [ - new Date("2026-04-25T00:00:01.000Z"), - new Date("2026-04-25T00:00:02.000Z"), - new Date("2026-04-25T00:00:03.000Z"), - new Date("2026-04-25T00:00:04.000Z"), - new Date("2026-04-25T00:00:05.000Z"), - new Date("2026-04-25T00:00:06.000Z"), - new Date("2026-04-25T00:00:07.000Z"), - new Date("2026-04-25T00:00:08.000Z"), - ]; + const now = new Date("2026-04-25T00:00:00Z"); + let time = +now; + const times: number[] = []; await seedRemoteAccount("replyer"); - await processDueRemoteReplyScrapeJobs({ - clock: () => requestTimes.shift() ?? new Date("2026-04-25T00:00:04.000Z"), - documentLoader: makeLoader({ - [repliesIri]: collection(repliesIri, [ - reply({ - id: "https://remote.test/@replyer/posts/1", - replyTarget: postIri, - }), - ]), - "https://remote.test/@replyer": actor("replyer"), - }), - intervalSeconds: 10, now, - sleep: async () => undefined, + clock: () => new Date(time), + intervalSeconds: 10, + sleep: async (ms) => { + time += ms; + }, + documentLoader: makeLoader( + { + [repliesIri]: collection(repliesIri, [ + reply({ id: "https://remote.test/posts/1", replyTarget: postIri }), + ]), + "https://remote.test/@replyer": actor("replyer"), + }, + () => times.push(time), + ), }); - const origin = await db.query.remoteReplyScrapeOrigins.findFirst(); - expect(origin?.lastRequestAt?.toISOString()).toBe( - "2026-04-25T00:00:06.000Z", - ); - expect(origin?.nextRequestAt.toISOString()).toBe( - "2026-04-25T00:00:16.000Z", - ); - expect(origin?.updated.toISOString()).toBe("2026-04-25T00:00:08.000Z"); + expect(times).toEqual([+now, +now + 10_000]); + expect(origin?.lastRequestAt).toEqual(new Date(times.at(-1)!)); + expect(origin?.nextRequestAt).toEqual(new Date(time + 10_000)); + expect(origin?.updated).toEqual(new Date(time)); }); }); @@ -1334,15 +1315,12 @@ describe("remote replies task delivery", () => { headers: { "Retry-After": "300" }, }), }); - const originalTransaction = db.transaction.bind(db); const transaction = vi.spyOn(db, "transaction"); const f = fixture({ now, intervalSeconds: 0, documentLoader: async () => { - transaction - .mockImplementationOnce(originalTransaction) - .mockRejectedValueOnce(new Error("connection reset")); + transaction.mockRejectedValueOnce(new Error("connection reset")); throw error; }, }); @@ -1757,3 +1735,1124 @@ it("a terminal scrape failure schedules its same-host sibling with spacing", asy }), ).toMatchObject({ status: "pending", attempts: 0 }); }); + +async function seedContextJob() { + const seed = await seedPostWithScrapeJob({ + repliesIri: "https://remote.test/conversation/1", + }); + await db + .update(remoteReplyScrapeJobs) + .set({ kind: "context" }) + .where(eq(remoteReplyScrapeJobs.id, seed.jobId)); + return seed; +} +function documentReply(options: Parameters[0]) { + return { + "@context": "https://www.w3.org/ns/activitystreams", + ...reply(options), + }; +} +async function gateReplies( + seed: Awaited>, + repliesIri = "https://remote.test/fallback", +) { + const post = await db.query.posts.findFirst({ + where: { id: { eq: seed.postId } }, + }); + await db.transaction((tx) => + enqueueRemoteReplyScrape(tx, { + baseUrl: "https://hollo.test", + post: post!, + contextIri: new URL(seed.repliesIri), + repliesIri: new URL(repliesIri), + }), + ); + return db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "replies" }, repliesIri: { eq: repliesIri } }, + }); +} +const contextOptions = { intervalSeconds: 0, sleep: async () => undefined }; + +describe("FEP-f228 conversation backfill", () => { + beforeEach(async () => { + await cleanDatabase(); + vi.restoreAllMocks(); + }); + + it("enqueues context-only seeds and gates each seed's distinct replies", async () => { + const seed = await seedContextJob(); + const first = await gateReplies(seed); + const second = await gateReplies( + seed, + "https://remote.test/fallback/second", + ); + expect(first?.status).toBe("waiting"); + expect(second?.blockedByJobId).toBe(seed.jobId); + expect( + await db.query.remoteReplyScrapeJobs.findMany({ + where: { kind: { eq: "context" } }, + }), + ).toHaveLength(1); + }); + + it("reads paginated contexts, reconciles out-of-order parents, and leaves missing ancestors unfetched", async () => { + const seed = await seedContextJob(); + await gateReplies(seed); + await seedRemoteAccount("replyer"); + const parent = "https://remote.test/posts/parent"; + const child = "https://remote.test/posts/child"; + const orphan = "https://remote.test/posts/orphan"; + const missing = "https://remote.test/posts/deleted"; + const page = `${seed.repliesIri}?page=1`; + const documents = { + [seed.repliesIri]: { ...collection(seed.repliesIri, []), first: page }, + [page]: { + "@context": "https://www.w3.org/ns/activitystreams", + id: page, + type: "OrderedCollectionPage", + partOf: seed.repliesIri, + orderedItems: [ + reply({ id: child, replyTarget: parent }), + reply({ id: parent, replyTarget: seed.postIri }), + reply({ id: orphan, replyTarget: missing }), + ], + }, + [child]: documentReply({ id: child, replyTarget: parent }), + [parent]: documentReply({ id: parent, replyTarget: seed.postIri }), + [orphan]: documentReply({ id: orphan, replyTarget: missing }), + "https://remote.test/@replyer": actor("replyer"), + }; + const urls: string[] = []; + expect( + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader(documents, (url) => urls.push(url)), + }), + ).toBe(3); + const parentPost = await db.query.posts.findFirst({ + where: { iri: { eq: parent } }, + }); + const childPost = await db.query.posts.findFirst({ + where: { iri: { eq: child } }, + }); + const orphanPost = await db.query.posts.findFirst({ + where: { iri: { eq: orphan } }, + }); + expect(childPost?.replyTargetId).toBe(parentPost?.id); + expect(parentPost?.repliesCount).toBe(1); + expect(orphanPost?.replyTargetId).toBeNull(); + expect(urls).not.toContain(missing); + expect(urls.filter((url) => url === child)).toHaveLength(1); + expect( + ( + await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "replies" } }, + }) + )?.status, + ).toBe("completed"); + }); + + it("covers waiting replies after a fully read empty context", async () => { + const seed = await seedContextJob(); + await gateReplies(seed); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader({ + [seed.repliesIri]: collection(seed.repliesIri, []), + }), + }); + const jobs = await db.query.remoteReplyScrapeJobs.findMany(); + expect(jobs.every((job) => job.status === "completed")).toBe(true); + expect(jobs.find((job) => job.kind === "context")?.partial).toBe(false); + }); + + it("releases replies when the context is missing and recovers orphaned gates", async () => { + const seed = await seedContextJob(); + await gateReplies(seed); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader({}), + }); + expect( + ( + await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "replies" } }, + }) + )?.status, + ).toBe("pending"); + await db + .update(remoteReplyScrapeJobs) + .set({ status: "waiting", blockedByJobId: null }) + .where(eq(remoteReplyScrapeJobs.kind, "replies")); + await fixture().tasks.reclaim(); + expect( + ( + await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "replies" } }, + }) + )?.status, + ).toBe("pending"); + }); + + it("counts existing objects against the yielded-item cap without changing their content", async () => { + const seed = await seedContextJob(); + await gateReplies(seed); + const knownIri = "https://remote.test/posts/known"; + const seedPost = await db.query.posts.findFirst({ + where: { id: { eq: seed.postId } }, + }); + await db.insert(posts).values({ + ...seedPost!, + id: uuidv7(), + iri: knownIri, + replyTargetId: seed.postId, + }); + const unseen = "https://remote.test/posts/unseen"; + const loader = vi.fn( + makeLoader({ + [seed.repliesIri]: collection(seed.repliesIri, [ + reply({ + id: knownIri, + replyTarget: seed.postIri, + content: "Forged replacement", + }), + reply({ id: unseen, replyTarget: seed.postIri }), + ]), + }), + ); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + maxItems: 1, + documentLoader: loader, + }); + const job = await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "context" } }, + }); + expect(job).toMatchObject({ + partial: true, + yieldedItems: 1, + fetchedItems: 1, + requestCount: 1, + }); + expect( + (await db.query.posts.findFirst({ where: { iri: { eq: knownIri } } })) + ?.content, + ).toBe("Root"); + expect( + await db.query.posts.findFirst({ where: { iri: { eq: unseen } } }), + ).toBeUndefined(); + expect( + ( + await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "replies" } }, + }) + )?.status, + ).toBe("pending"); + }); + + it("shares the request cap with actor persistence and rolls back incomplete items", async () => { + const seed = await seedContextJob(); + const id = "https://remote.test/posts/budget"; + const loader = vi.fn( + makeLoader({ + [seed.repliesIri]: collection(seed.repliesIri, [ + reply({ id, replyTarget: seed.postIri }), + ]), + [id]: documentReply({ id, replyTarget: seed.postIri }), + "https://remote.test/@replyer": actor("replyer"), + }), + ); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + maxRequests: 2, + documentLoader: loader, + }); + expect(loader).toHaveBeenCalledTimes(2); + expect( + await db.query.posts.findFirst({ where: { iri: { eq: id } } }), + ).toBeUndefined(); + expect((await db.query.remoteReplyScrapeJobs.findFirst())!).toMatchObject({ + status: "completed", + partial: true, + requestCount: 2, + }); + }); + + it("refetches embedded objects and rejects forged, local, and mismatched identities", async () => { + const seed = await seedContextJob(); + const id = "https://remote.test/posts/forged"; + const local = "https://hollo.test/posts/local"; + const wrongActor = "https://remote.test/posts/foreign-author"; + const good = "https://remote.test/posts/good"; + await seedRemoteAccount("replyer"); + const documents = { + [seed.repliesIri]: collection(seed.repliesIri, [ + reply({ id, replyTarget: seed.postIri }), + reply({ id: local, replyTarget: seed.postIri }), + reply({ id: wrongActor, replyTarget: seed.postIri }), + reply({ id: good, replyTarget: seed.postIri, content: "Forged" }), + ]), + [id]: documentReply({ + id: "https://foreign.test/posts/forged", + replyTarget: seed.postIri, + }), + [wrongActor]: documentReply({ + id: wrongActor, + replyTarget: seed.postIri, + host: "foreign.test", + }), + [good]: documentReply({ + id: good, + replyTarget: seed.postIri, + content: "Authoritative", + }), + "https://remote.test/@replyer": actor("replyer"), + }; + const urls: string[] = []; + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader(documents, (url) => urls.push(url)), + }); + const saved = await db.query.posts.findMany(); + expect(saved.map((post) => post.iri).sort()).toEqual( + [seed.postIri, good].sort(), + ); + expect(saved.find((post) => post.iri === good)?.contentHtml).toBe( + "

Authoritative

", + ); + expect(urls).not.toContain(local); + }); + + it("retains own-host Retry-After backoff and keeps fallbacks gated", async () => { + const seed = await seedContextJob(); + await gateReplies(seed); + const now = new Date(); + const error = Object.assign(new Error("limited"), { + response: new Response(null, { + status: 429, + headers: { "Retry-After": "120" }, + }), + }); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + now, + documentLoader: async () => { + throw error; + }, + }); + const job = await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "context" } }, + }); + expect(job).toMatchObject({ status: "pending", requestCount: 1 }); + expect(job?.nextAttemptAt).toEqual(new Date(+now + 120_000)); + expect( + ( + await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "replies" } }, + }) + )?.status, + ).toBe("waiting"); + }); + + it("skips a foreign host after 429, caches failures, and continues healthy items", async () => { + const seed = await seedContextJob(); + await gateReplies(seed); + const bad = "https://foreign.test/posts/bad"; + const later = "https://foreign.test/posts/later"; + const good = "https://remote.test/posts/good"; + await seedRemoteAccount("replyer"); + const base = makeLoader({ + [seed.repliesIri]: collection(seed.repliesIri, [ + reply({ id: bad, replyTarget: seed.postIri }), + reply({ id: later, replyTarget: seed.postIri }), + reply({ id: good, replyTarget: seed.postIri }), + ]), + [good]: documentReply({ id: good, replyTarget: seed.postIri }), + "https://remote.test/@replyer": actor("replyer"), + }); + const urls: string[] = []; + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: async (url) => { + urls.push(url); + if (new URL(url).host === "foreign.test") + throw Object.assign(new Error("foreign limited"), { + response: new Response(null, { + status: 429, + headers: { "Retry-After": "300" }, + }), + }); + return base(url); + }, + }); + expect(urls.filter((url) => new URL(url).host === "foreign.test")).toEqual([ + bad, + ]); + expect( + await db.query.posts.findFirst({ where: { iri: { eq: good } } }), + ).toBeDefined(); + expect( + (await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "context" } }, + }))!, + ).toMatchObject({ status: "completed", partial: true }); + }); + + it("merges resolved aliases into a fresh completed job and settles late seed gates", async () => { + const seed = await seedContextJob(); + const canonical = "https://remote.test/conversation/canonical"; + const winnerId = uuidv7(); + await db.insert(remoteReplyScrapeJobs).values({ + id: winnerId, + kind: "context", + postId: seed.postId, + postIri: seed.postIri, + repliesIri: canonical, + originHost: "remote.test", + baseUrl: "https://hollo.test", + status: "completed", + completedAt: new Date(), + }); + await gateReplies(seed); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader({ + [seed.repliesIri]: collection(canonical, []), + }), + }); + expect( + await db.query.remoteReplyScrapeJobs.findMany({ + where: { kind: { eq: "context" } }, + }), + ).toHaveLength(1); + const alias = await db.query.remoteContextScrapeAliases.findFirst({ + where: { iri: { eq: seed.repliesIri } }, + }); + expect(alias?.jobId).toBe(winnerId); + expect( + ( + await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "replies" } }, + }) + )?.status, + ).toBe("completed"); + }); + + it("replaces expired canonical jobs instead of suppressing a new traversal", async () => { + const seed = await seedContextJob(); + const canonical = "https://remote.test/conversation/canonical"; + await db.insert(remoteReplyScrapeJobs).values({ + id: uuidv7(), + kind: "context", + postId: seed.postId, + postIri: seed.postIri, + repliesIri: canonical, + originHost: "remote.test", + baseUrl: "https://hollo.test", + status: "completed", + completedAt: new Date(0), + }); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader({ + [seed.repliesIri]: collection(canonical, []), + }), + }); + const jobs = await db.query.remoteReplyScrapeJobs.findMany(); + expect(jobs).toHaveLength(1); + expect(jobs[0]).toMatchObject({ + id: seed.jobId, + repliesIri: canonical, + status: "completed", + }); + }); + it("does not hold actor host locks while fetching persistence dependencies", async () => { + const seed = await seedContextJob(); + const id = "https://remote.test/posts/new-author"; + const followingIri = "https://remote.test/@fresh/following"; + let checked = false; + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 404 })), + ); + try { + const base = makeLoader({ + [seed.repliesIri]: collection(seed.repliesIri, [ + reply({ id, replyTarget: seed.postIri, username: "fresh" }), + ]), + [id]: documentReply({ + id, + replyTarget: seed.postIri, + username: "fresh", + }), + "https://remote.test/@fresh": { + ...actor("fresh"), + preferredUsername: "fresh", + following: followingIri, + }, + [followingIri]: collection(followingIri, []), + "https://remote.test/@fresh/followers": collection( + "https://remote.test/@fresh/followers", + [], + ), + }); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: async (url) => { + if (url === followingIri) { + await db.transaction(async (tx) => { + await tx.execute( + sql`select host from ${instances} where host = 'remote.test' for update nowait`, + ); + }); + checked = true; + } + return base(url); + }, + }); + expect(checked).toBe(true); + expect( + await db.query.posts.findFirst({ where: { iri: { eq: id } } }), + ).toBeDefined(); + expect((await db.query.remoteReplyScrapeJobs.findFirst())?.partial).toBe( + false, + ); + } finally { + vi.unstubAllGlobals(); + } + }); + it("leaves an interrupted poll unsaved and retries it completely", async () => { + const seed = await seedContextJob(); + const id = "https://remote.test/posts/poll"; + const option = "https://remote.test/posts/poll/choice"; + await seedRemoteAccount("replyer"); + const controller = new AbortController(); + const documents = { + [seed.repliesIri]: collection(seed.repliesIri, [ + reply({ id, replyTarget: seed.postIri }), + ]), + [id]: { + ...documentReply({ id, replyTarget: seed.postIri }), + type: "Question", + oneOf: option, + endTime: "2030-01-01T00:00:00Z", + }, + [option]: { + "@context": "https://www.w3.org/ns/activitystreams", + id: option, + type: "Note", + name: "Yes", + }, + "https://remote.test/@replyer": actor("replyer"), + }; + const base = makeLoader(documents); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + signal: controller.signal, + documentLoader: async (url) => { + if (url === option) + controller.abort(new Error("shutdown during preparation")); + return base(url); + }, + }); + expect( + await db.query.posts.findFirst({ where: { iri: { eq: id } } }), + ).toBeUndefined(); + expect((await db.query.remoteReplyScrapeJobs.findFirst())?.status).toBe( + "pending", + ); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: base, + }); + const post = await db.query.posts.findFirst({ where: { iri: { eq: id } } }); + expect(post?.pollId).toBeTruthy(); + expect(await db.query.pollOptions.findMany()).toHaveLength(1); + }); + it("exposes reconstructed context through the API with privacy, mute, and block filtering", async () => { + const { default: app } = await import("../index"); + const viewer = await createAccount(); + const client = await createOAuthApplication({ scopes: ["read:statuses"] }); + const token = await getAccessToken(client, viewer, ["read:statuses"]); + const seed = await seedContextJob(); + const author = await seedRemoteAccount("replyer"); + const visible = "https://remote.test/posts/public"; + const privateIri = "https://remote.test/posts/private"; + const documents = { + [seed.repliesIri]: collection(seed.repliesIri, [ + reply({ id: visible, replyTarget: seed.postIri }), + reply({ id: privateIri, replyTarget: seed.postIri }), + ]), + [visible]: documentReply({ id: visible, replyTarget: seed.postIri }), + [privateIri]: { + ...documentReply({ id: privateIri, replyTarget: seed.postIri }), + to: "https://remote.test/@replyer/followers", + }, + "https://remote.test/@replyer": actor("replyer"), + }; + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader(documents), + }); + const publicPost = await db.query.posts.findFirst({ + where: { iri: { eq: visible } }, + }); + const path = `/api/v1/statuses/${seed.postId}/context`; + const headers = { authorization: bearerAuthorization(token) }; + const anonymous = await app.request(path); + expect(anonymous.status).toBe(200); + expect( + (await anonymous.json()).descendants.map( + (post: { id: string }) => post.id, + ), + ).toEqual([publicPost!.id]); + const authenticated = await app.request(path, { headers }); + expect((await authenticated.json()).descendants).toHaveLength(1); + const childContext = await app.request( + `/api/v1/statuses/${publicPost!.id}/context`, + ); + expect((await childContext.json()).ancestors[0].id).toBe(seed.postId); + await db.insert(mutes).values({ + id: uuidv7(), + accountId: viewer.id, + mutedAccountId: author.id, + }); + expect( + (await (await app.request(path, { headers })).json()).descendants, + ).toHaveLength(0); + await db.delete(mutes); + await db + .insert(blocks) + .values({ accountId: viewer.id, blockedAccountId: author.id }); + expect( + (await (await app.request(path, { headers })).json()).descendants, + ).toHaveLength(0); + const timelineBefore = await db.query.timelinePosts.findMany(); + await db + .update(remoteReplyScrapeJobs) + .set({ + status: "pending", + completedAt: null, + nextAttemptAt: new Date(0), + nextDispatchAt: new Date(0), + }) + .where(eq(remoteReplyScrapeJobs.id, seed.jobId)); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader(documents), + }); + expect(await db.query.timelinePosts.findMany()).toEqual(timelineBefore); + expect(await db.query.notifications.findMany()).toHaveLength(0); + expect( + (await db.query.posts.findFirst({ where: { id: { eq: seed.postId } } })) + ?.repliesCount, + ).toBe(2); + }); + it.each(["child-first", "parent-first"])( + "applies home and list reply policies with %s context items", + async (order) => { + const seed = await seedContextJob(); + const viewer = await createAccount(); + const childAuthor = await seedRemoteAccount("replyer"); + const parentAuthor = await seedRemoteAccount("parent"); + await db.insert(follows).values({ + iri: `https://local.test/follows/${uuidv7()}`, + followerId: viewer.id, + followingId: childAuthor.id, + approved: new Date(), + }); + const excludedList = uuidv7(); + const includedList = uuidv7(); + await db.insert(lists).values([ + { + id: excludedList, + accountOwnerId: viewer.id, + title: "No replies", + repliesPolicy: "none", + }, + { + id: includedList, + accountOwnerId: viewer.id, + title: "Member replies", + repliesPolicy: "list", + }, + ]); + await db.insert(listMembers).values([ + { listId: excludedList, accountId: childAuthor.id }, + { listId: includedList, accountId: childAuthor.id }, + { listId: includedList, accountId: parentAuthor.id }, + ]); + const childIri = "https://remote.test/posts/child"; + const parentIri = "https://remote.test/posts/parent"; + const child = reply({ id: childIri, replyTarget: parentIri }); + const parent = { + ...reply({ id: parentIri, replyTarget: seed.postIri }), + attributedTo: parentAuthor.iri, + }; + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader({ + [seed.repliesIri]: collection( + seed.repliesIri, + order === "child-first" ? [child, parent] : [parent, child], + ), + [childIri]: { + "@context": "https://www.w3.org/ns/activitystreams", + ...child, + }, + [parentIri]: { + "@context": "https://www.w3.org/ns/activitystreams", + ...parent, + }, + [childAuthor.iri]: actor("replyer"), + [parentAuthor.iri]: actor("parent"), + }), + }); + const savedChild = await db.query.posts.findFirst({ + where: { iri: { eq: childIri } }, + }); + const savedParent = await db.query.posts.findFirst({ + where: { iri: { eq: parentIri } }, + }); + expect(savedChild?.replyTargetId).toBe(savedParent?.id); + expect( + await db.query.timelinePosts.findMany({ + where: { postId: { eq: savedChild!.id } }, + }), + ).toEqual([]); + expect( + ( + await db.query.listPosts.findMany({ + where: { postId: { eq: savedChild!.id } }, + }) + ).map((row) => row.listId), + ).toEqual([includedList]); + }, + ); + it("terminates cached quote cycles within the shared request budget", async () => { + const seed = await seedContextJob(); + await seedRemoteAccount("replyer"); + const first = "https://remote.test/posts/quote-a"; + const second = "https://remote.test/posts/quote-b"; + const quoted = async (id: string, other: string) => + new Note({ + id: new URL(id), + attribution: new URL("https://remote.test/@replyer"), + replyTarget: new URL(seed.postIri), + quote: new URL(other), + to: new URL(PUBLIC_COLLECTION), + content: "Quote", + }).toJsonLd(); + const loader = vi.fn( + makeLoader({ + [seed.repliesIri]: collection(seed.repliesIri, [ + reply({ id: first, replyTarget: seed.postIri }), + ]), + [first]: await quoted(first, second), + [second]: await quoted(second, first), + "https://remote.test/@replyer": actor("replyer"), + }), + ); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + maxRequests: 4, + documentLoader: loader, + }); + expect((await db.query.remoteReplyScrapeJobs.findFirst())?.status).toBe( + "completed", + ); + expect(loader).toHaveBeenCalledTimes(4); + expect(await db.query.posts.findMany()).toHaveLength(3); + }); + + it("terminates actor successor cycles with a successful document cache", async () => { + const seed = await seedContextJob(); + const id = "https://remote.test/posts/actor-cycle"; + const first = "https://remote.test/@cycle-a"; + const second = "https://remote.test/@cycle-b"; + const person = async (iri: string, successor: string, username: string) => + new Person({ + id: new URL(iri), + name: username, + preferredUsername: username, + inbox: new URL(`${iri}/inbox`), + successor: new URL(successor), + }).toJsonLd(); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 404 })), + ); + try { + const loader = vi.fn( + makeLoader({ + [seed.repliesIri]: collection(seed.repliesIri, [ + reply({ id, replyTarget: seed.postIri, username: "cycle-a" }), + ]), + [id]: documentReply({ + id, + replyTarget: seed.postIri, + username: "cycle-a", + }), + [first]: await person(first, second, "cycle-a"), + [second]: await person(second, first, "cycle-b"), + }), + ); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + maxRequests: 4, + documentLoader: loader, + }); + expect((await db.query.remoteReplyScrapeJobs.findFirst())?.status).toBe( + "completed", + ); + expect( + await db.query.posts.findFirst({ where: { iri: { eq: id } } }), + ).toBeDefined(); + expect(loader).toHaveBeenCalledTimes(4); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("hands redirected context identity to its canonical host before traversal", async () => { + const seed = await seedContextJob(); + await gateReplies(seed); + const canonical = "https://canonical.test/conversation/1"; + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: async () => ({ + contextUrl: null, + documentUrl: canonical, + document: collection(canonical, []), + }), + }); + expect( + (await db.query.remoteReplyScrapeJobs.findFirst({ + where: { id: { eq: seed.jobId } }, + }))!, + ).toMatchObject({ + status: "pending", + originHost: "canonical.test", + hostRequeues: 1, + }); + expect( + ( + await db.query.remoteReplyScrapeOrigins.findFirst({ + where: { originHost: { eq: "remote.test" } }, + }) + )?.processingJobId, + ).toBeNull(); + const urls: string[] = []; + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader( + { [canonical]: collection(canonical, []) }, + (url) => urls.push(url), + ), + }); + expect(urls).toEqual([canonical]); + expect( + ( + await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "replies" } }, + }) + )?.status, + ).toBe("completed"); + }); + it("accepts Create wrappers, deduplicates posts, and skips other activities", async () => { + const seed = await seedContextJob(); + await seedRemoteAccount("replyer"); + const id = "https://remote.test/posts/created"; + const updated = "https://remote.test/posts/update-only"; + const note = reply({ id, replyTarget: seed.postIri }); + const urls: string[] = []; + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader( + { + [seed.repliesIri]: collection(seed.repliesIri, [ + { + id: `${id}#create`, + type: "Create", + actor: "https://remote.test/@replyer", + object: note, + }, + note, + { + id: `${updated}#update`, + type: "Update", + actor: "https://remote.test/@replyer", + object: reply({ id: updated, replyTarget: seed.postIri }), + }, + { + id: `${updated}#delete`, + type: "Delete", + actor: "https://remote.test/@replyer", + object: updated, + }, + ]), + [id]: documentReply({ id, replyTarget: seed.postIri }), + "https://remote.test/@replyer": actor("replyer"), + }, + (url) => urls.push(url), + ), + }); + expect( + await db.query.posts.findFirst({ where: { iri: { eq: id } } }), + ).toBeDefined(); + expect( + await db.query.posts.findFirst({ where: { iri: { eq: updated } } }), + ).toBeUndefined(); + expect(urls).not.toContain(updated); + expect(urls.filter((url) => url === id)).toHaveLength(1); + expect((await db.query.remoteReplyScrapeJobs.findFirst())!).toMatchObject({ + status: "completed", + fetchedItems: 1, + }); + }); + + it("falls back for a resolved non-collection context", async () => { + const seed = await seedContextJob(); + await gateReplies(seed); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader({ + [seed.repliesIri]: documentReply({ + id: seed.repliesIri, + replyTarget: seed.postIri, + }), + }), + }); + expect( + ( + await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "context" } }, + }) + )?.status, + ).toBe("failed"); + expect( + ( + await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "replies" } }, + }) + )?.status, + ).toBe("pending"); + }); + it("repairs the stored seed when its missing parent is in the collection", async () => { + const seed = await seedContextJob(); + const parent = "https://remote.test/posts/seed-parent"; + await seedRemoteAccount("replyer"); + const documents = { + [seed.repliesIri]: collection(seed.repliesIri, [ + reply({ id: seed.postIri, replyTarget: parent, username: "author" }), + reply({ id: parent, replyTarget: "https://remote.test/posts/deleted" }), + ]), + [seed.postIri]: documentReply({ + id: seed.postIri, + replyTarget: parent, + username: "author", + }), + [parent]: documentReply({ + id: parent, + replyTarget: "https://remote.test/posts/deleted", + }), + "https://remote.test/@replyer": actor("replyer"), + }; + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader(documents), + }); + const parentPost = await db.query.posts.findFirst({ + where: { iri: { eq: parent } }, + }); + const seedPost = await db.query.posts.findFirst({ + where: { id: { eq: seed.postId } }, + }); + expect(seedPost?.replyTargetId).toBe(parentPost?.id); + expect(seedPost?.content).toBe("Root"); + expect(parentPost?.repliesCount).toBe(1); + }); + + it("repairs verified orphan links locally when the request cap stops traversal", async () => { + const seed = await seedContextJob(); + await seedRemoteAccount("replyer"); + const child = "https://remote.test/posts/capped-child"; + const parent = "https://remote.test/posts/capped-parent"; + const unseen = "https://remote.test/posts/capped-unseen"; + const loader = vi.fn( + makeLoader({ + [seed.repliesIri]: collection(seed.repliesIri, [ + reply({ id: child, replyTarget: parent }), + reply({ id: parent, replyTarget: seed.postIri }), + reply({ id: unseen, replyTarget: seed.postIri }), + ]), + [child]: documentReply({ id: child, replyTarget: parent }), + [parent]: documentReply({ id: parent, replyTarget: seed.postIri }), + "https://remote.test/@replyer": actor("replyer"), + }), + ); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + maxRequests: 4, + documentLoader: loader, + }); + const parentPost = await db.query.posts.findFirst({ + where: { iri: { eq: parent } }, + }); + expect( + (await db.query.posts.findFirst({ where: { iri: { eq: child } } })) + ?.replyTargetId, + ).toBe(parentPost?.id); + expect(parentPost?.repliesCount).toBe(1); + expect(loader).toHaveBeenCalledTimes(4); + expect((await db.query.remoteReplyScrapeJobs.findFirst())!).toMatchObject({ + status: "completed", + partial: true, + }); + }); + + it.each(["first", "next", "embedded"])( + "releases fallback when a %s page is a Note", + async (kind) => { + const seed = await seedContextJob(); + await gateReplies(seed); + const invalid = `${seed.repliesIri}/invalid`; + const badPage = documentReply({ id: invalid, replyTarget: seed.postIri }); + const root = + kind === "next" + ? { + ...collection(seed.repliesIri, []), + first: { + id: `${seed.repliesIri}/first`, + type: "OrderedCollectionPage", + next: invalid, + orderedItems: [], + }, + } + : { + ...collection(seed.repliesIri, []), + first: kind === "embedded" ? badPage : invalid, + }; + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader({ + [seed.repliesIri]: root, + [invalid]: badPage, + }), + }); + expect( + (await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "context" } }, + }))!, + ).toMatchObject({ + status: kind === "embedded" ? "failed" : "completed", + partial: true, + }); + expect( + ( + await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "replies" } }, + }) + )?.status, + ).toBe("pending"); + }, + ); + it("still follows valid next pages after inspecting their references", async () => { + const seed = await seedContextJob(); + await seedRemoteAccount("replyer"); + const first = `${seed.repliesIri}/first`; + const second = `${seed.repliesIri}/second`; + const id = "https://remote.test/posts/next-page"; + const urls: string[] = []; + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + documentLoader: makeLoader( + { + [seed.repliesIri]: { ...collection(seed.repliesIri, []), first }, + [first]: { + "@context": "https://www.w3.org/ns/activitystreams", + id: first, + type: "OrderedCollectionPage", + next: second, + orderedItems: [], + }, + [second]: { + "@context": "https://www.w3.org/ns/activitystreams", + id: second, + type: "OrderedCollectionPage", + orderedItems: [reply({ id, replyTarget: seed.postIri })], + }, + [id]: documentReply({ id, replyTarget: seed.postIri }), + "https://remote.test/@replyer": actor("replyer"), + }, + (url) => urls.push(url), + ), + }); + expect(urls).toContain(second); + expect( + await db.query.posts.findFirst({ where: { iri: { eq: id } } }), + ).toBeDefined(); + expect((await db.query.remoteReplyScrapeJobs.findFirst())!).toMatchObject({ + status: "completed", + partial: false, + }); + }); + it.each(["replies", "context"] as const)( + "completes %s jobs with intervals above 60 seconds while keeping the lease fresh", + async (kind) => { + const seed = + kind === "context" + ? await seedContextJob() + : await seedPostWithScrapeJob(); + await seedRemoteAccount("replyer"); + const now = new Date("2026-04-25T00:00:00Z"); + let time = +now; + const clock = () => new Date(time); + const recovery = fixture({ clock, staleProcessingSeconds: 15 }); + const id = "https://remote.test/posts/long-spacing"; + const times: number[] = []; + const leaseStates: (string | undefined)[] = []; + await processDueRemoteReplyScrapeJobs({ + now, + clock, + intervalSeconds: 120, + staleProcessingSeconds: 15, + sleep: async (ms) => { + time += ms; + if ((time - +now) % 60_000 === 0) { + await recovery.tasks.reclaim(); + leaseStates.push( + ( + await db.query.remoteReplyScrapeJobs.findFirst({ + where: { id: { eq: seed.jobId } }, + }) + )?.status, + ); + } + }, + documentLoader: makeLoader( + { + [seed.repliesIri]: collection(seed.repliesIri, [ + reply({ id, replyTarget: seed.postIri }), + ]), + [id]: documentReply({ id, replyTarget: seed.postIri }), + "https://remote.test/@replyer": actor("replyer"), + }, + () => times.push(time), + ), + }); + expect( + (await db.query.remoteReplyScrapeJobs.findFirst({ + where: { id: { eq: seed.jobId } }, + }))!, + ).toMatchObject({ status: "completed", fetchedItems: 1, attempts: 1 }); + expect( + await db.query.posts.findFirst({ where: { iri: { eq: id } } }), + ).toBeDefined(); + expect(leaseStates.length).toBeGreaterThan(0); + expect(new Set(leaseStates)).toEqual(new Set(["processing"])); + for (let i = 1; i < times.length; i++) + expect(times[i] - times[i - 1]).toBeGreaterThanOrEqual(120_000); + }, + ); +}); diff --git a/src/federation/replies-worker.ts b/src/federation/replies-worker.ts index b41676ac..34d7b048 100644 --- a/src/federation/replies-worker.ts +++ b/src/federation/replies-worker.ts @@ -1,29 +1,60 @@ +import { backfill } from "@fedify/backfill"; import type { Context } from "@fedify/fedify"; -import { Collection, type DocumentLoader, lookupObject } from "@fedify/vocab"; +import { + Collection, + CollectionPage, + type DocumentLoader, + Note, +} from "@fedify/vocab"; import { and, eq, isNull, lte, sql } from "drizzle-orm"; -import db from "../db"; +import db, { type DatabaseLike } from "../db"; +import { FEDIFY_ORIGIN } from "../env"; import { posts, + listPosts, + timelinePosts, type RemoteReplyScrapeJob, remoteReplyScrapeJobs, remoteReplyScrapeOrigins, } from "../schema"; import type { Uuid } from "../uuid"; import { iterateCollection } from "./collection"; -import { isPost, persistPost } from "./post"; +import { isPost, persistPost, type ASPost } from "./post"; import { enqueueRemoteReplyScrape, + isHttpIri, + settleContextReplies, laterBySeconds, REMOTE_REPLIES_SCRAPE_BACKOFF_SECONDS, REMOTE_REPLIES_SCRAPE_DEPTH, REMOTE_REPLIES_SCRAPE_INTERVAL_SECONDS, REMOTE_REPLIES_SCRAPE_MAX_ITEMS, + REMOTE_REPLIES_SCRAPE_MAX_REQUESTS, } from "./replies"; +import { canonicalizeContextJob } from "./replies-context"; +import { + createScrapeLoader, + ScrapeDeferred, + ScrapeRequestLimit, +} from "./replies-loader"; +import { + LostScrapeAttempt, + lockScrapeAttempt, + processingJobAttemptCondition, +} from "./replies-state"; +import { appendPostToTimelines } from "./timeline"; export const STALE_PROCESSING_TIMEOUT_SECONDS = 15 * 60; -class LostScrapeAttempt extends Error {} +interface ScrapeProgress { + fetchedItems: number; + yieldedItems: number; + requestCount: number; + skippedItems: number; + partial: boolean; + errorMessage: string | null; +} export interface ProcessRemoteReplyScrapeJobsOptions { signal?: AbortSignal; @@ -33,6 +64,7 @@ export interface ProcessRemoteReplyScrapeJobsOptions { intervalSeconds?: number; maxDepth?: number; maxItems?: number; + maxRequests?: number; maxJobs?: number; now?: Date; sleep?: (milliseconds: number) => Promise; @@ -145,102 +177,360 @@ export async function processRemoteReplyScrapeJob( } let fetchedItems = 0; - let lastFetchError: unknown; - let lastFetchErrorUrl: URL | undefined; - const isLastFetchOriginRateLimit = (error: unknown = lastFetchError) => - error === lastFetchError && - isOriginRateLimit(error, lastFetchErrorUrl, job); - - try { - const documentLoader = + let yieldedItems = 0; + let skippedItems = 0; + let partial = false; + const parents = new Set(); + const orphanHints = new Map(); + const verifiedParents = new Map(); + let defaultLoader: Promise | undefined; + const loader = createScrapeLoader({ + getJob: () => job, + documentLoader: options.documentLoader ?? - (await getDefaultDocumentLoader(ctx, job.baseUrl)); - const throttledDocumentLoader = createThrottledDocumentLoader(job, { - documentLoader, - intervalSeconds: - options.intervalSeconds ?? REMOTE_REPLIES_SCRAPE_INTERVAL_SECONDS, - staleProcessingSeconds: - options.staleProcessingSeconds ?? STALE_PROCESSING_TIMEOUT_SECONDS, - clock, - sleep: options.sleep ?? ((ms) => sleep(ms, options.signal)), - checkpoint, + ((url, loaderOptions) => + (defaultLoader ??= getDefaultDocumentLoader(ctx, job.baseUrl)).then( + (load) => load(url, loaderOptions), + )), + contextLoader: ctx.contextLoader, + maxRequests: options.maxRequests ?? REMOTE_REPLIES_SCRAPE_MAX_REQUESTS, + intervalSeconds: Math.max( + 0, + options.intervalSeconds ?? REMOTE_REPLIES_SCRAPE_INTERVAL_SECONDS, + ), + backoffSeconds: Math.max( + 0, + options.backoffSeconds ?? REMOTE_REPLIES_SCRAPE_BACKOFF_SECONDS, + ), + clock, + checkpoint, + sleep: options.sleep ?? ((ms) => sleep(ms, options.signal)), + signal: options.signal, + }); + const progress = (): ScrapeProgress => ({ + fetchedItems, + yieldedItems, + skippedItems: skippedItems + loader.skippedItems, + requestCount: loader.requestCount, + partial: partial || loader.partial, + errorMessage: loader.errorMessage, + }); + const maxItems = options.maxItems ?? REMOTE_REPLIES_SCRAPE_MAX_ITEMS; + const persistOptions = { + documentLoader: loader.documentLoader, + contextLoader: loader.contextLoader, + objectLoader: loader.lookup, + enqueueRemoteReplies: false, + fetchEmojiReactions: false, + skipUpdate: true, + ...(job.kind === "context" + ? { fetchReplyTarget: false } + : { replyTarget: post }), + }; + const authoritativePost = async (object: ASPost) => { + const id = object.id; + if ( + id == null || + !isHttpIri(id) || + id.origin === ctx.origin || + id.origin === new URL(job.baseUrl).origin || + id.origin === FEDIFY_ORIGIN?.webOrigin || + id.host === FEDIFY_ORIGIN?.handleHost + ) + return null; + const fetched = await loader.lookup(id); + loader.check(); + if ( + !isPost(fetched) || + fetched.id?.href !== id.href || + fetched.attributionId == null || + fetched.attributionId.origin !== id.origin + ) + return null; + verifiedParents.set(id.href, fetched.replyTargetId); + return fetched; + }; + const persistItem = async (object: ASPost) => { + await checkpoint(); + loader.check(); + const saved = await persistPost(db, object, job.baseUrl, { + ...persistOptions, + persistPrepared: async (write) => { + loader.check(); + return await db.transaction(async (tx) => { + await lockScrapeAttempt(tx, job); + loader.check(); + const result = await write(tx); + if (result?.replyTargetId != null) + await updateScrapedRepliesCount(result.replyTargetId, tx); + loader.check(); + return result; + }); + }, }); - const recordingDocumentLoader: DocumentLoader = async ( - url, - loadOptions, - ) => { - try { - return await throttledDocumentLoader(url, loadOptions); - } catch (error) { - if (error instanceof LostScrapeAttempt) lost = error; - if (options.signal?.aborted) interrupted = true; - lastFetchError = error; - lastFetchErrorUrl = new URL(url); - throw error; + if (saved?.replyTargetId != null) parents.add(saved.replyTargetId); + await checkpoint(); + return saved; + }; + // Relink only authoritative orphan metadata, after out-of-order parents + // have arrived. Existing canonical relationships are never overwritten. + const reconcile = async (localOnly = false) => { + for (const [childIri, hintedParent] of orphanHints) { + if (localOnly) await checkpoint(); + else loader.check(); + const parent = await db.query.posts.findFirst({ + where: { iri: { eq: hintedParent.href } }, + }); + if (parent == null) continue; + if (localOnly && !verifiedParents.has(childIri)) continue; + const parentIri = verifiedParents.has(childIri) + ? verifiedParents.get(childIri) + : (await authoritativePost(new Note({ id: new URL(childIri) }))) + ?.replyTargetId; + if (parentIri == null || parentIri.href === childIri) continue; + await db.transaction(async (tx) => { + await lockScrapeAttempt(tx, job); + if (localOnly) options.signal?.throwIfAborted(); + else loader.check(); + const actualParent = await tx.query.posts.findFirst({ + where: { iri: { eq: parentIri.href } }, + }); + const child = await tx.query.posts.findFirst({ + where: { iri: { eq: childIri } }, + }); + if ( + actualParent == null || + child == null || + child.replyTargetId != null || + actualParent.id === child.id + ) + return; + const ancestry = await tx.execute<{ id: string }>(sql` + with recursive ancestors as ( + select id, reply_target_id from ${posts} where id = ${actualParent.id} + union select p.id, p.reply_target_id from ${posts} p join ancestors a on p.id = a.reply_target_id + ) select id from ancestors where id = ${child.id} + `); + if (ancestry.length > 0) return; + await tx + .update(posts) + .set({ replyTargetId: actualParent.id }) + .where(and(eq(posts.id, child.id), isNull(posts.replyTargetId))); + // The child may have entered inboxes as a root before its parent + // arrived. Reapply the existing reply policies in the same transaction. + const repaired = await tx.query.posts.findFirst({ + where: { id: { eq: child.id } }, + with: { + mentions: true, + sharing: { with: { mentions: true } }, + replyTarget: true, + }, + }); + if (repaired != null) { + await tx + .delete(timelinePosts) + .where(eq(timelinePosts.postId, child.id)); + await tx.delete(listPosts).where(eq(listPosts.postId, child.id)); + await appendPostToTimelines(tx, repaired); + } + await updateScrapedRepliesCount(actualParent.id, tx); + parents.add(actualParent.id); + }); + } + }; + const pageTargets = new Set(); + const inspectedCollections = new WeakSet(); + const inspectPageReferences = async (collection: Collection) => { + if (inspectedCollections.has(collection)) return; + inspectedCollections.add(collection); + // Expanded JSON-LD distinguishes embedded pages from URL references + // without fetching pages ahead of traversal or logging fake failures. + const namespace = "https://www.w3.org/ns/activitystreams#"; + const inspect = (value: unknown) => { + if (Array.isArray(value)) { + for (const entry of value) inspect(entry); + return; + } + if (value == null || typeof value !== "object") return; + const document = value as Record; + const types = document["@type"]; + const isPage = + Array.isArray(types) && + (types.includes(`${namespace}CollectionPage`) || + types.includes(`${namespace}OrderedCollectionPage`)); + const references = [ + document[`${namespace}first`], + ...(isPage ? [document[`${namespace}next`]] : []), + ]; + for (const reference of references) { + for (const target of Array.isArray(reference) ? reference : []) { + if (target == null || typeof target !== "object") continue; + const page = target as Record; + if (typeof page["@id"] === "string") pageTargets.add(page["@id"]); + const pageTypes = page["@type"]; + if (!Array.isArray(pageTypes) || pageTypes.length === 0) continue; + if ( + pageTypes.includes(`${namespace}CollectionPage`) || + pageTypes.includes(`${namespace}OrderedCollectionPage`) + ) + inspect(page); + else { + partial = true; + skippedItems++; + } + } } }; - const collection = await lookupObject(new URL(job.repliesIri), { - documentLoader: recordingDocumentLoader, - }); - - if (collection == null && isLastFetchOriginRateLimit()) { - throw lastFetchError; - } - - if (collection == null) { - throw new Error(`Replies collection not found: ${job.repliesIri}`); - } - - if (!(collection instanceof Collection)) { - throw new Error( - `Replies collection is not a Collection: ${job.repliesIri}`, - ); + inspect(await collection.toJsonLd({ format: "expand" })); + loader.check(); + }; + const loadBackfillObject = async (iri: URL) => { + const object = await loader.lookup(iri); + if (pageTargets.has(iri.href) && !(object instanceof CollectionPage)) { + partial = true; + skippedItems++; + return null; } - - for await (const item of iterateCollection(collection, { - documentLoader: recordingDocumentLoader, - })) { - if ( - fetchedItems >= (options.maxItems ?? REMOTE_REPLIES_SCRAPE_MAX_ITEMS) - ) { - break; + if (object instanceof Collection) await inspectPageReferences(object); + return object; + }; + try { + if ( + maxItems <= 0 || + (options.maxRequests ?? REMOTE_REPLIES_SCRAPE_MAX_REQUESTS) <= 0 + ) { + partial = true; + } else { + const requested = new URL(job.repliesIri); + const collection = await (job.kind === "context" + ? loader.loadObject(requested) + : loader.lookup(requested)); + if (!(collection instanceof Collection)) { + throw new Error( + `${job.kind === "context" ? "Context" : "Replies"} collection not found: ${job.repliesIri}`, + ); } - if (!isPost(item)) continue; - - await checkpoint(); - const reply = await persistPost(db, item, job.baseUrl, { - documentLoader: recordingDocumentLoader, - enqueueRemoteReplies: false, - fetchEmojiReactions: false, - replyTarget: post, - skipUpdate: true, - }); - await checkpoint(); - if (reply == null) continue; - - fetchedItems++; - const childRepliesIri = item.repliesId; - if ( - childRepliesIri != null && - job.depth + 1 < (options.maxDepth ?? REMOTE_REPLIES_SCRAPE_DEPTH) - ) { - await enqueueRemoteReplyScrape(db, { - baseUrl: job.baseUrl, - depth: job.depth + 1, - post: reply, - repliesIri: childRepliesIri, - }); + if (job.kind === "context") { + const finalUrl = await loader.documentUrl(requested); + const canonical = collection.id ?? finalUrl; + if (!isHttpIri(canonical) || canonical.origin !== finalUrl.origin) + throw new Error("Unusable context collection identity"); + const promoted = await canonicalizeContextJob( + job, + canonical, + finalUrl, + clock(), + ); + if (promoted == null) return 0; + job = promoted; + loader.cacheObject(collection, [requested, finalUrl, canonical]); + await inspectPageReferences(collection); + } + const items = + job.kind === "context" + ? (async function* () { + for await (const item of backfill( + { documentLoader: loadBackfillObject }, + new Note({ + // Include the stored seed in collection-based orphan repair. + contexts: [new URL(job.repliesIri)], + }), + { + strategies: ["context-auto"], + maxItems, + signal: loader.signal, + }, + )) + yield item.object; + })() + : iterateCollection(collection, { + documentLoader: loader.documentLoader, + contextLoader: loader.contextLoader, + }); + for await (const candidate of items) { + loader.check(); + await checkpoint(); + yieldedItems++; + if (!isPost(candidate)) { + partial = true; + skippedItems++; + continue; + } + let item: ASPost = candidate; + if (job.kind === "context") { + const existing = + item.id == null + ? null + : await db.query.posts.findFirst({ + where: { iri: { eq: item.id.href } }, + }); + if (existing != null) { + fetchedItems++; + if (existing.replyTargetId == null && item.replyTargetId != null) + orphanHints.set(existing.iri, item.replyTargetId); + continue; + } + const trusted = await authoritativePost(item); + if (trusted == null) { + partial = true; + skippedItems++; + continue; + } + item = trusted; + } + let saved; + try { + saved = await persistItem(item); + } catch (error) { + loader.check(); + if (error instanceof LostScrapeAttempt || job.kind === "replies") + throw error; + // A malformed item must not prevent later conversation items. + partial = true; + skippedItems++; + continue; + } + if (saved == null) { + partial = true; + skippedItems++; + continue; + } + fetchedItems++; + if ( + job.kind === "context" && + saved.replyTargetId == null && + item.replyTargetId != null + ) + orphanHints.set(saved.iri, item.replyTargetId); + if (job.kind === "replies") { + if ( + item.repliesId != null && + job.depth + 1 < (options.maxDepth ?? REMOTE_REPLIES_SCRAPE_DEPTH) + ) { + await enqueueRemoteReplyScrape(db, { + baseUrl: job.baseUrl, + depth: job.depth + 1, + post: saved, + repliesIri: item.repliesId, + }); + } + if (fetchedItems >= maxItems) { + partial = true; + break; + } + } } + loader.check(); + if (job.kind === "context" && yieldedItems >= maxItems) partial = true; + await reconcile(); } - await checkpoint(); - await updateScrapedRepliesCount(job.postId); - await completeJob(job, fetchedItems, clock()); + await completeJob(job, progress(), clock()); return fetchedItems; } catch (error) { if (lost != null || error instanceof LostScrapeAttempt) return 0; - if (interrupted || options.signal?.aborted) { - await interruptJob(job, clock()); + if (options.signal?.aborted || interrupted) { + await interruptJob(job, clock(), progress()); return 0; } try { @@ -248,32 +538,52 @@ export async function processRemoteReplyScrapeJob( } catch (checkpointError) { if (lost != null || checkpointError instanceof LostScrapeAttempt) return 0; - if (interrupted || options.signal?.aborted) { - await interruptJob(job, clock()); + if (options.signal?.aborted || interrupted) { + await interruptJob(job, clock(), progress()); return 0; } - // A transient heartbeat failure must not discard the scrape error. - // Outcome writes below still fence this attempt against replacement. } - await updateScrapedRepliesCount(job.postId); - if (isLastFetchOriginRateLimit(error)) { - const failedAt = clock(); + if (error instanceof ScrapeDeferred && error.host === job.originHost) { await backOffJob( job, - retryAfterSeconds(error, failedAt) ?? - options.backoffSeconds ?? - REMOTE_REPLIES_SCRAPE_BACKOFF_SECONDS, - error, - failedAt, + Math.max(0, (+error.retryAt - +clock()) / 1000), + error.cause ?? error, + clock(), + progress(), ); return 0; } + if (error instanceof ScrapeRequestLimit) { + partial = true; + // The loader is deliberately aborted at the cap. Repair only links + // from metadata already verified in this attempt, without new I/O. + try { + await reconcile(true); + await checkpoint(); + } catch (reconcileError) { + if (lost != null || reconcileError instanceof LostScrapeAttempt) + return 0; + if (options.signal?.aborted || interrupted) { + await interruptJob(job, clock(), progress()); + return 0; + } + throw reconcileError; + } + await completeJob(job, progress(), clock()); + return fetchedItems; + } await failJob( job, error instanceof Error ? error.message : String(error), clock(), + progress(), ); return 0; + } finally { + // These counts derive solely from committed rows, so a lost lease must + // not strand counts for children this attempt already committed. + parents.add(job.postId); + for (const parentId of parents) await updateScrapedRepliesCount(parentId); } } finally { clearInterval(timer); @@ -281,130 +591,6 @@ export async function processRemoteReplyScrapeJob( } } -function createThrottledDocumentLoader( - job: RemoteReplyScrapeJob, - { - documentLoader, - intervalSeconds, - staleProcessingSeconds, - clock, - sleep, - checkpoint, - }: { - checkpoint: () => Promise; - clock: () => Date; - documentLoader: DocumentLoader; - intervalSeconds: number; - sleep: (milliseconds: number) => Promise; - staleProcessingSeconds: number; - }, -): DocumentLoader { - let originRequests = 0; - - return async (url, options) => { - const sameOrigin = new URL(url).host === job.originHost; - if (sameOrigin && originRequests > 0) { - await sleepWithProcessingHeartbeats(job, { - clock, - seconds: intervalSeconds, - sleep, - staleProcessingSeconds, - }); - } - if (sameOrigin) originRequests++; - - try { - await checkpoint(); - return await documentLoader(url, options); - } finally { - const requestTime = clock(); - await db.transaction(async (tx) => { - const [updatedJob] = await tx - .update(remoteReplyScrapeJobs) - .set({ updated: requestTime }) - .where(processingJobAttemptCondition(job)) - .returning({ id: remoteReplyScrapeJobs.id }); - if (updatedJob == null) { - await releaseOriginLeaseIfJobDeleted(tx, job, requestTime); - return; - } - - if (sameOrigin) { - await tx - .update(remoteReplyScrapeOrigins) - .set({ - lastRequestAt: requestTime, - nextRequestAt: laterBySeconds(intervalSeconds, requestTime), - processingStartedAt: requestTime, - updated: requestTime, - }) - .where( - and( - eq(remoteReplyScrapeOrigins.originHost, job.originHost), - eq(remoteReplyScrapeOrigins.processingJobId, job.id), - ), - ); - } else { - await tx - .update(remoteReplyScrapeOrigins) - .set({ - processingStartedAt: requestTime, - updated: requestTime, - }) - .where( - and( - eq(remoteReplyScrapeOrigins.originHost, job.originHost), - eq(remoteReplyScrapeOrigins.processingJobId, job.id), - ), - ); - } - }); - } - }; -} - -function processingJobAttemptCondition(job: RemoteReplyScrapeJob) { - return and( - eq(remoteReplyScrapeJobs.id, job.id), - eq(remoteReplyScrapeJobs.status, "processing"), - eq(remoteReplyScrapeJobs.attempts, job.attempts), - job.startedAt == null - ? isNull(remoteReplyScrapeJobs.startedAt) - : eq(remoteReplyScrapeJobs.startedAt, job.startedAt), - ); -} - -async function sleepWithProcessingHeartbeats( - job: RemoteReplyScrapeJob, - { - clock, - seconds, - sleep, - staleProcessingSeconds, - }: { - clock: () => Date; - seconds: number; - sleep: (milliseconds: number) => Promise; - staleProcessingSeconds: number; - }, -): Promise { - let remainingMilliseconds = seconds * 1000; - if (remainingMilliseconds <= 0) return; - - const heartbeatMilliseconds = - Math.max(1, Math.floor(staleProcessingSeconds / 2)) * 1000; - - while (remainingMilliseconds > 0) { - await updateProcessingHeartbeat(job, clock()); - const sleepMilliseconds = Math.min( - remainingMilliseconds, - heartbeatMilliseconds, - ); - await sleep(sleepMilliseconds); - remainingMilliseconds -= sleepMilliseconds; - } -} - async function updateProcessingHeartbeat( job: RemoteReplyScrapeJob, now: Date, @@ -443,8 +629,9 @@ async function updateProcessingHeartbeat( async function updateScrapedRepliesCount( postId: RemoteReplyScrapeJob["postId"], + database: DatabaseLike = db, ) { - await db.execute(sql` + await database.execute(sql` update ${posts} set replies_count = ( select count(*) @@ -457,7 +644,7 @@ async function updateScrapedRepliesCount( async function completeJob( job: RemoteReplyScrapeJob, - fetchedItems: number, + progress: ScrapeProgress, now: Date, ): Promise { await db.transaction(async (tx) => { @@ -466,18 +653,20 @@ async function completeJob( .set({ status: "completed", nextDispatchAt: now, - fetchedItems, + ...progress, completedAt: now, - errorMessage: null, updated: now, }) .where(processingJobAttemptCondition(job)) - .returning({ id: remoteReplyScrapeJobs.id }); + .returning(); if (updatedJob == null) { await releaseOriginLeaseIfJobDeleted(tx, job, now); return; } + if (updatedJob.kind === "context") + await settleContextReplies(tx, updatedJob, now); + await tx .update(remoteReplyScrapeOrigins) .set({ @@ -498,24 +687,32 @@ async function failJob( job: RemoteReplyScrapeJob, message: string, now: Date, + progress?: ScrapeProgress, ): Promise { await db.transaction(async (tx) => { const [updatedJob] = await tx .update(remoteReplyScrapeJobs) .set({ + ...progress, status: "failed", nextDispatchAt: now, - errorMessage: message, + errorMessage: + job.kind === "context" && progress?.errorMessage + ? `${message}; ${progress.errorMessage}` + : message, completedAt: now, updated: now, }) .where(processingJobAttemptCondition(job)) - .returning({ id: remoteReplyScrapeJobs.id }); + .returning(); if (updatedJob == null) { await releaseOriginLeaseIfJobDeleted(tx, job, now); return; } + if (updatedJob.kind === "context") + await settleContextReplies(tx, updatedJob, now); + await tx .update(remoteReplyScrapeOrigins) .set({ @@ -537,31 +734,41 @@ async function backOffJob( seconds: number, error: unknown, now: Date, + progress?: ScrapeProgress, ): Promise { const nextAttemptAt = laterBySeconds(seconds, now); await db.transaction(async (tx) => { const [updatedJob] = await tx .update(remoteReplyScrapeJobs) .set({ + ...progress, status: "pending", nextDispatchAt: now, nextAttemptAt, - errorMessage: error instanceof Error ? error.message : String(error), + errorMessage: + job.kind === "context" && progress?.errorMessage + ? progress.errorMessage + : error instanceof Error + ? error.message + : String(error), startedAt: null, completedAt: null, updated: now, }) .where(processingJobAttemptCondition(job)) - .returning({ id: remoteReplyScrapeJobs.id }); + .returning(); if (updatedJob == null) { await releaseOriginLeaseIfJobDeleted(tx, job, now); return; } + if (updatedJob.kind === "context") + await settleContextReplies(tx, updatedJob, now); + await tx .update(remoteReplyScrapeOrigins) .set({ - nextRequestAt: nextAttemptAt, + nextRequestAt: sql`greatest(${remoteReplyScrapeOrigins.nextRequestAt}, ${nextAttemptAt.toISOString()}::timestamptz)`, processingJobId: null, processingStartedAt: null, updated: sql`greatest(${remoteReplyScrapeOrigins.updated}, ${now.toISOString()}::timestamptz)`, @@ -615,50 +822,6 @@ async function getDefaultDocumentLoader( return await context.getDocumentLoader({ username: owner.handle }); } -function getErrorStatus(error: unknown): number | null { - if ( - error == null || - typeof error !== "object" || - !("response" in error) || - !(error.response instanceof Response) - ) { - return null; - } - return error.response.status; -} - -function isOriginRateLimit( - error: unknown, - errorUrl: URL | undefined, - job: RemoteReplyScrapeJob, -): boolean { - return getErrorStatus(error) === 429 && errorUrl?.host === job.originHost; -} - -function retryAfterSeconds(error: unknown, now = new Date()): number | null { - if ( - error == null || - typeof error !== "object" || - !("response" in error) || - !(error.response instanceof Response) - ) { - return null; - } - - const retryAfter = error.response.headers.get("Retry-After"); - if (retryAfter == null) return null; - - const trimmedRetryAfter = retryAfter.trim(); - if (/^-?\d+$/.test(trimmedRetryAfter)) { - const seconds = Number.parseInt(trimmedRetryAfter, 10); - return seconds >= 0 ? seconds : null; - } - - const date = Date.parse(trimmedRetryAfter); - if (Number.isNaN(date)) return null; - return Math.max(0, Math.ceil((date - now.getTime()) / 1000)); -} - function sleep(milliseconds: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { signal?.throwIfAborted(); @@ -674,11 +837,16 @@ function sleep(milliseconds: number, signal?: AbortSignal): Promise { }); } -async function interruptJob(job: RemoteReplyScrapeJob, now: Date) { +async function interruptJob( + job: RemoteReplyScrapeJob, + now: Date, + progress?: ScrapeProgress, +) { await db.transaction(async (tx) => { const [owned] = await tx .update(remoteReplyScrapeJobs) .set({ + ...progress, status: "pending", nextDispatchAt: now, startedAt: null, diff --git a/src/federation/replies.ts b/src/federation/replies.ts index 71ef61e6..c745dfde 100644 --- a/src/federation/replies.ts +++ b/src/federation/replies.ts @@ -1,8 +1,9 @@ import { getLogger } from "@logtape/logtape"; -import { and, count, eq, inArray } from "drizzle-orm"; +import { and, count, eq, inArray, sql } from "drizzle-orm"; -import database, { type DatabaseLike } from "../db"; +import database, { type DatabaseLike, type Transaction } from "../db"; import { + remoteContextScrapeAliases, remoteReplyScrapeJobs, remoteReplyScrapeOrigins, type Post, @@ -18,6 +19,10 @@ export const REMOTE_REPLIES_SCRAPE_MAX_ITEMS = parsePositiveInteger( "REMOTE_REPLIES_SCRAPE_MAX_ITEMS", 100, ); +export const REMOTE_REPLIES_SCRAPE_MAX_REQUESTS = parsePositiveInteger( + "REMOTE_REPLIES_SCRAPE_MAX_REQUESTS", + 200, +); export const REMOTE_REPLIES_SCRAPE_INTERVAL_SECONDS = parsePositiveInteger( "REMOTE_REPLIES_SCRAPE_INTERVAL_SECONDS", 5, @@ -33,6 +38,15 @@ export const REMOTE_REPLIES_SCRAPE_COOLDOWN_SECONDS = parseNonNegativeInteger( type Database = DatabaseLike; +/** Serialize collection identity changes, never remote I/O. */ +export async function lockContextScrapeIdentity(tx: Transaction) { + await tx.execute(sql`select pg_advisory_xact_lock(1213156428, 1129270868)`); +} + +export function isHttpIri(iri: URL): boolean { + return iri.protocol === "https:" || iri.protocol === "http:"; +} + export async function enqueueRemoteReplyScrape( db: Database, { @@ -40,98 +54,217 @@ export async function enqueueRemoteReplyScrape( depth = 0, post, repliesIri, + contextIri, }: { baseUrl: URL | string; depth?: number; post: Post; - repliesIri: URL; + repliesIri?: URL | null; + contextIri?: URL | null; }, ): Promise { - if (REMOTE_REPLIES_SCRAPE_DEPTH < 1) return; - if (depth >= REMOTE_REPLIES_SCRAPE_DEPTH) return; - + if (REMOTE_REPLIES_SCRAPE_DEPTH < 1 || depth >= REMOTE_REPLIES_SCRAPE_DEPTH) + return; + const context = + contextIri != null && isHttpIri(contextIri) ? contextIri : null; + const replies = + repliesIri != null && isHttpIri(repliesIri) ? repliesIri : null; + if (context == null && replies == null) return; const now = new Date(); - const originHost = repliesIri.host; - const baseUrlString = - typeof baseUrl === "string" ? new URL(baseUrl).origin : baseUrl.origin; - const cooldownStartedAt = new Date( - now.getTime() - REMOTE_REPLIES_SCRAPE_COOLDOWN_SECONDS * 1000, - ); - - const jobId = await db.transaction(async (tx) => { - await tx - .insert(remoteReplyScrapeOrigins) - .values({ - originHost, - }) - .onConflictDoNothing(); - - // Lock an existing generation before resetting an expired cooldown. - const [existingJob] = await tx - .select() - .from(remoteReplyScrapeJobs) - .where(eq(remoteReplyScrapeJobs.repliesIri, repliesIri.href)) - .for("update"); - - if ( - existingJob != null && - isActiveOrCoolingDown(existingJob, cooldownStartedAt) - ) { - return; - } - - const values = { - postId: post.id, - postIri: post.iri, - repliesIri: repliesIri.href, - baseUrl: baseUrlString, - originHost, - depth, - status: "pending" as const, - attempts: 0, - fetchedItems: 0, - nextAttemptAt: now, - nextDispatchAt: now, - errorMessage: null, - startedAt: null, - completedAt: null, - updated: now, - }; - - if (existingJob != null) { + const baseUrlString = new URL(baseUrl).origin; + const cutoff = laterBySeconds(-REMOTE_REPLIES_SCRAPE_COOLDOWN_SECONDS, now); + const dispatchIds = await db.transaction(async (tx) => { + const ids: RemoteReplyScrapeJob["id"][] = []; + let blocker: RemoteReplyScrapeJob | undefined; + if (context != null) { + await lockContextScrapeIdentity(tx); + const alias = await tx.query.remoteContextScrapeAliases.findFirst({ + where: { iri: { eq: context.href } }, + }); + const [existing] = await tx + .select() + .from(remoteReplyScrapeJobs) + .where( + alias == null + ? and( + eq(remoteReplyScrapeJobs.kind, "context"), + eq(remoteReplyScrapeJobs.repliesIri, context.href), + ) + : eq(remoteReplyScrapeJobs.id, alias.jobId), + ) + .for("update"); + blocker = await createOrResetJob(tx, existing, { + post, + iri: context, + baseUrl: baseUrlString, + depth: 0, + kind: "context", + now, + cutoff, + }); await tx - .update(remoteReplyScrapeJobs) - .set(values) - .where(eq(remoteReplyScrapeJobs.id, existingJob.id)); - return existingJob.id; + .insert(remoteContextScrapeAliases) + .values({ iri: context.href, jobId: blocker.id }) + .onConflictDoUpdate({ + target: remoteContextScrapeAliases.iri, + set: { jobId: blocker.id }, + }); + if (blocker.status === "pending") ids.push(blocker.id); + if (blocker.status === "completed" && !blocker.partial) return ids; } - const [created] = await tx - .insert(remoteReplyScrapeJobs) - .values({ ...values, id: uuidv7(), created: now }) - .onConflictDoNothing({ target: remoteReplyScrapeJobs.repliesIri }) - .returning({ id: remoteReplyScrapeJobs.id }); - return created?.id; + if (replies != null) { + const [existing] = await tx + .select() + .from(remoteReplyScrapeJobs) + .where( + and( + eq(remoteReplyScrapeJobs.kind, "replies"), + eq(remoteReplyScrapeJobs.repliesIri, replies.href), + ), + ) + .for("update"); + if (existing != null && isActiveOrCoolingDown(existing, cutoff)) + return ids; + const waiting = + blocker?.status === "pending" || blocker?.status === "processing"; + const job = await createOrResetJob(tx, existing, { + post, + iri: replies, + baseUrl: baseUrlString, + depth, + kind: "replies", + now, + cutoff, + blockedByJobId: waiting ? blocker!.id : null, + }); + if (job.status === "pending") ids.push(job.id); + } + return ids; }); - - // A nested transaction is only a savepoint. Its caller may still roll - // back, so only the application's root handle may dispatch immediately. - // Committed rows created through other handles are picked up by recovery. - if (jobId != null && db === database) { - try { - const { federation, replyScrapes } = await import("./federation"); - await replyScrapes.enqueue( - federation.createContext(new URL(baseUrlString), undefined), - jobId, - ); - } catch (error) { - getLogger(["hollo", "federation", "replies-worker"]).error( - "Scrape {jobId} committed but dispatch failed; recovery will retry: {error}", - { jobId, error }, - ); + // Transaction handles only create durable rows; recovery dispatches after + // their outer transaction commits. + if (db === database) { + for (const jobId of dispatchIds) { + try { + const { federation, replyScrapes } = await import("./federation"); + await replyScrapes.enqueue( + federation.createContext(new URL(baseUrlString), undefined), + jobId, + ); + } catch (error) { + getLogger(["hollo", "federation", "replies-worker"]).error( + "Scrape {jobId} committed but dispatch failed; recovery will retry: {error}", + { jobId, error }, + ); + } } } } +async function createOrResetJob( + tx: Transaction, + existing: RemoteReplyScrapeJob | undefined, + options: { + post: Post; + iri: URL; + baseUrl: string; + depth: number; + kind: "context" | "replies"; + now: Date; + cutoff: Date; + blockedByJobId?: RemoteReplyScrapeJob["id"] | null; + }, +): Promise { + if (existing != null && isActiveOrCoolingDown(existing, options.cutoff)) + return existing; + const iri = + existing?.kind === "context" ? new URL(existing.repliesIri) : options.iri; + await tx + .insert(remoteReplyScrapeOrigins) + .values({ originHost: iri.host }) + .onConflictDoNothing(); + const values = { + postId: options.post.id, + postIri: options.post.iri, + repliesIri: iri.href, + kind: options.kind, + baseUrl: options.baseUrl, + originHost: iri.host, + depth: options.depth, + status: + options.blockedByJobId != null + ? ("waiting" as const) + : ("pending" as const), + blockedByJobId: options.blockedByJobId ?? null, + attempts: 0, + fetchedItems: 0, + yieldedItems: 0, + requestCount: 0, + skippedItems: 0, + partial: false, + hostRequeues: 0, + nextAttemptAt: options.now, + nextDispatchAt: options.now, + errorMessage: null, + startedAt: null, + completedAt: null, + updated: options.now, + }; + if (existing != null) { + const [job] = await tx + .update(remoteReplyScrapeJobs) + .set(values) + .where(eq(remoteReplyScrapeJobs.id, existing.id)) + .returning(); + return job; + } + const [created] = await tx + .insert(remoteReplyScrapeJobs) + .values({ ...values, id: uuidv7(), created: options.now }) + .onConflictDoNothing({ + target: [remoteReplyScrapeJobs.kind, remoteReplyScrapeJobs.repliesIri], + }) + .returning(); + if (created != null) return created; + const [concurrent] = await tx + .select() + .from(remoteReplyScrapeJobs) + .where( + and( + eq(remoteReplyScrapeJobs.kind, options.kind), + eq(remoteReplyScrapeJobs.repliesIri, iri.href), + ), + ) + .for("update"); + return concurrent; +} + +/** Called only after locking/updating the context blocker. */ +export async function settleContextReplies( + tx: Transaction, + blocker: RemoteReplyScrapeJob, + now: Date, +) { + if (blocker.status !== "completed" && blocker.status !== "failed") return; + const covered = blocker.status === "completed" && !blocker.partial; + await tx + .update(remoteReplyScrapeJobs) + .set({ + status: covered ? "completed" : "pending", + blockedByJobId: null, + nextDispatchAt: now, + completedAt: covered ? now : null, + errorMessage: covered ? "Covered by context collection" : null, + updated: now, + }) + .where( + and( + eq(remoteReplyScrapeJobs.status, "waiting"), + eq(remoteReplyScrapeJobs.blockedByJobId, blocker.id), + ), + ); +} + export async function countActiveRemoteReplyScrapeJobs( db: Database, repliesIri: URL, @@ -142,20 +275,27 @@ export async function countActiveRemoteReplyScrapeJobs( .where( and( eq(remoteReplyScrapeJobs.repliesIri, repliesIri.href), - inArray(remoteReplyScrapeJobs.status, ["pending", "processing"]), + eq(remoteReplyScrapeJobs.kind, "replies"), + inArray(remoteReplyScrapeJobs.status, [ + "pending", + "processing", + "waiting", + ]), ), ); return row?.count ?? 0; } -function isActiveOrCoolingDown( +export function isActiveOrCoolingDown( job: RemoteReplyScrapeJob, cooldownStartedAt: Date, ): boolean { return ( job.status === "pending" || job.status === "processing" || - (job.status === "completed" && + job.status === "waiting" || + ((job.status === "completed" || + (job.kind === "context" && job.status === "failed")) && job.completedAt != null && job.completedAt > cooldownStartedAt) ); diff --git a/src/schema.ts b/src/schema.ts index 4f765dc6..7996120c 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1341,12 +1341,17 @@ export type NewCleanupJobItem = typeof cleanupJobItems.$inferInsert; export const remoteReplyScrapeJobStatusEnum = pgEnum( "remote_reply_scrape_job_status", - ["pending", "processing", "completed", "failed"], + ["pending", "processing", "completed", "failed", "waiting"], ); export type RemoteReplyScrapeJobStatus = (typeof remoteReplyScrapeJobStatusEnum.enumValues)[number]; +export const remoteReplyScrapeKindEnum = pgEnum("remote_reply_scrape_kind", [ + "replies", + "context", +]); + export const remoteReplyScrapeJobs = pgTable( "remote_reply_scrape_jobs", { @@ -1356,7 +1361,19 @@ export const remoteReplyScrapeJobs = pgTable( .notNull() .references(() => posts.id, { onDelete: "cascade" }), postIri: text("post_iri").notNull(), - repliesIri: text("replies_iri").notNull().unique(), + // The legacy column also holds the collection target of context jobs. + repliesIri: text("replies_iri").notNull(), + kind: remoteReplyScrapeKindEnum("kind").notNull().default("replies"), + blockedByJobId: uuid("blocked_by_job_id") + .$type() + .references((): AnyPgColumn => remoteReplyScrapeJobs.id, { + onDelete: "set null", + }), + partial: boolean("partial").notNull().default(false), + yieldedItems: integer("yielded_items").notNull().default(0), + requestCount: integer("request_count").notNull().default(0), + skippedItems: integer("skipped_items").notNull().default(0), + hostRequeues: integer("host_requeues").notNull().default(0), baseUrl: text("base_url").notNull(), originHost: text("origin_host").notNull(), depth: integer("depth").notNull().default(0), @@ -1382,6 +1399,11 @@ export const remoteReplyScrapeJobs = pgTable( .default(currentTimestamp), }, (table) => [ + uniqueIndex("remote_reply_scrape_jobs_kind_collection_unique").on( + table.kind, + table.repliesIri, + ), + index().on(table.blockedByJobId), index().on(table.postId), index("remote_reply_scrape_jobs_dispatch_index") .on(table.status, table.nextDispatchAt, table.id) @@ -1407,6 +1429,18 @@ export const remoteReplyScrapeJobs = pgTable( export type RemoteReplyScrapeJob = typeof remoteReplyScrapeJobs.$inferSelect; export type NewRemoteReplyScrapeJob = typeof remoteReplyScrapeJobs.$inferInsert; +export const remoteContextScrapeAliases = pgTable( + "remote_context_scrape_aliases", + { + iri: text("iri").primaryKey(), + jobId: uuid("job_id") + .$type() + .notNull() + .references(() => remoteReplyScrapeJobs.id, { onDelete: "cascade" }), + }, + (table) => [index().on(table.jobId)], +); + export const remoteReplyScrapeOrigins = pgTable( "remote_reply_scrape_origins", { @@ -1415,6 +1449,9 @@ export const remoteReplyScrapeOrigins = pgTable( .notNull() .default(currentTimestamp), lastRequestAt: timestamp("last_request_at", { withTimezone: true }), + cooldownUntil: timestamp("cooldown_until", { withTimezone: true }) + .notNull() + .default(sql`'epoch'::timestamptz`), processingJobId: uuid("processing_job_id").$type(), processingStartedAt: timestamp("processing_started_at", { withTimezone: true, From a6257448a76d5ad96e5a7f1d82bfa6a5c66706ca Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 9 Oct 2026 14:17:29 +0900 Subject: [PATCH 2/4] Bound remote Retry-After delays Cap numeric and date-based Retry-After delays at seven days so a remote 429 cannot overflow Date and fail a scrape instead of deferring it. Cover oversized values, numeric overflow and far-future dates while retaining ordinary backoff and waiting replies jobs. https://github.com/fedify-dev/hollo/pull/664#discussion_r4226224975 Assisted-by: Codex:gpt-6.1-sol --- src/federation/replies-loader.ts | 9 +++- src/federation/replies-worker.test.ts | 68 +++++++++++++++------------ 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/src/federation/replies-loader.ts b/src/federation/replies-loader.ts index df9620af..b70b5a30 100644 --- a/src/federation/replies-loader.ts +++ b/src/federation/replies-loader.ts @@ -34,6 +34,8 @@ export function getErrorStatus(error: unknown): number | null { : null; } +const MAX_RETRY_AFTER_SECONDS = 7 * 24 * 60 * 60; + export function retryAfterSeconds( error: unknown, now = new Date(), @@ -49,12 +51,15 @@ export function retryAfterSeconds( if (value == null) return null; if (/^-?\d+$/.test(value)) { const seconds = Number.parseInt(value, 10); - return seconds >= 0 ? seconds : null; + return seconds >= 0 ? Math.min(seconds, MAX_RETRY_AFTER_SECONDS) : null; } const date = Date.parse(value); return Number.isNaN(date) ? null - : Math.max(0, Math.ceil((date - now.getTime()) / 1000)); + : Math.min( + MAX_RETRY_AFTER_SECONDS, + Math.max(0, Math.ceil((date - now.getTime()) / 1000)), + ); } /** One invocation budget and cache shared by traversal and persistence. */ diff --git a/src/federation/replies-worker.test.ts b/src/federation/replies-worker.test.ts index f4558651..dfd979d9 100644 --- a/src/federation/replies-worker.test.ts +++ b/src/federation/replies-worker.test.ts @@ -2023,36 +2023,44 @@ describe("FEP-f228 conversation backfill", () => { expect(urls).not.toContain(local); }); - it("retains own-host Retry-After backoff and keeps fallbacks gated", async () => { - const seed = await seedContextJob(); - await gateReplies(seed); - const now = new Date(); - const error = Object.assign(new Error("limited"), { - response: new Response(null, { - status: 429, - headers: { "Retry-After": "120" }, - }), - }); - await processDueRemoteReplyScrapeJobs({ - ...contextOptions, - now, - documentLoader: async () => { - throw error; - }, - }); - const job = await db.query.remoteReplyScrapeJobs.findFirst({ - where: { kind: { eq: "context" } }, - }); - expect(job).toMatchObject({ status: "pending", requestCount: 1 }); - expect(job?.nextAttemptAt).toEqual(new Date(+now + 120_000)); - expect( - ( - await db.query.remoteReplyScrapeJobs.findFirst({ - where: { kind: { eq: "replies" } }, - }) - )?.status, - ).toBe("waiting"); - }); + it.each([ + ["120", 120], + ["10000000000000", 7 * 24 * 60 * 60], + ["9".repeat(400), 7 * 24 * 60 * 60], + ["Fri, 01 Jan 9999 00:00:00 GMT", 7 * 24 * 60 * 60], + ] as const)( + "backs off own-host Retry-After %s and keeps fallbacks gated", + async (header, seconds) => { + const seed = await seedContextJob(); + await gateReplies(seed); + const now = new Date(); + const error = Object.assign(new Error("limited"), { + response: new Response(null, { + status: 429, + headers: { "Retry-After": header }, + }), + }); + await processDueRemoteReplyScrapeJobs({ + ...contextOptions, + now, + documentLoader: async () => { + throw error; + }, + }); + const job = await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "context" } }, + }); + expect(job).toMatchObject({ status: "pending", requestCount: 1 }); + expect(job?.nextAttemptAt).toEqual(new Date(+now + seconds * 1000)); + expect( + ( + await db.query.remoteReplyScrapeJobs.findFirst({ + where: { kind: { eq: "replies" } }, + }) + )?.status, + ).toBe("waiting"); + }, + ); it("skips a foreign host after 429, caches failures, and continues healthy items", async () => { const seed = await seedContextJob(); From d0affcf417f40bf130a89e0a1d74f115ad6f73c8 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 9 Oct 2026 14:19:40 +0900 Subject: [PATCH 3/4] Throttle scrape pacing heartbeats Keep cancellation checks on each one-second sleep chunk, but space pacing heartbeats across waits. Use at most thirty seconds and at most one third of the stale timeout so short leases remain fresh. Retain request-boundary ownership checks. Cover heartbeat counts across multiple waits and preserve the existing long-interval recovery tests. https://github.com/fedify-dev/hollo/pull/664#pullrequestreview-5465247207 Assisted-by: Codex:gpt-6.1-sol --- src/federation/replies-loader.ts | 9 +++++- src/federation/replies-worker.test.ts | 40 +++++++++++++++++++++++++++ src/federation/replies-worker.ts | 4 +++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/federation/replies-loader.ts b/src/federation/replies-loader.ts index b70b5a30..dfddbfda 100644 --- a/src/federation/replies-loader.ts +++ b/src/federation/replies-loader.ts @@ -72,6 +72,7 @@ export function createScrapeLoader({ backoffSeconds, clock, checkpoint, + checkpointIntervalMilliseconds = 30_000, sleep, signal: shutdown, }: { @@ -83,6 +84,7 @@ export function createScrapeLoader({ backoffSeconds: number; clock: () => Date; checkpoint: () => Promise; + checkpointIntervalMilliseconds?: number; sleep: (milliseconds: number) => Promise; signal?: AbortSignal; }) { @@ -107,14 +109,19 @@ export function createScrapeLoader({ partial = true; }; + let sinceCheckpoint = Infinity; async function wait(milliseconds: number) { let remaining = milliseconds; while (remaining > 0) { check(); - await checkpoint(); + if (sinceCheckpoint >= checkpointIntervalMilliseconds) { + await checkpoint(); + sinceCheckpoint = 0; + } const chunk = Math.min(remaining, 1000); await sleep(chunk); remaining -= chunk; + sinceCheckpoint += chunk; } check(); } diff --git a/src/federation/replies-worker.test.ts b/src/federation/replies-worker.test.ts index dfd979d9..4fd21296 100644 --- a/src/federation/replies-worker.test.ts +++ b/src/federation/replies-worker.test.ts @@ -32,6 +32,7 @@ import { import type { Uuid } from "../uuid"; import { uuidv7 } from "../uuid"; import { enqueueRemoteReplyScrape } from "./replies"; +import { createScrapeLoader } from "./replies-loader"; import { registerRemoteReplyScrapes } from "./replies-tasks"; import { claimRemoteReplyScrapeJob as claimById, @@ -2062,6 +2063,45 @@ describe("FEP-f228 conversation backfill", () => { }, ); + it.each([ + [30_000, 5], + [5_000, 7], + ])( + "paces requests with a %s ms heartbeat interval across sleeps", + async (checkpointIntervalMilliseconds, expectedCheckpoints) => { + const seed = await seedContextJob(); + const job = (await db.query.remoteReplyScrapeJobs.findFirst({ + where: { id: { eq: seed.jobId } }, + }))!; + let time = +new Date(); + const checkpoint = vi.fn(async () => undefined); + const sleep = vi.fn(async (ms: number) => { + time += ms; + }); + const urls = [0, 1, 2, 3].map( + (index) => `https://remote.test/paced/${index}`, + ); + const documentLoader = makeLoader( + Object.fromEntries(urls.map((url) => [url, collection(url, [])])), + ); + const loader = createScrapeLoader({ + getJob: () => job, + documentLoader, + contextLoader: documentLoader, + maxRequests: 4, + intervalSeconds: 5, + backoffSeconds: 0, + clock: () => new Date(time), + checkpoint, + checkpointIntervalMilliseconds, + sleep, + }); + for (const url of urls) await loader.documentLoader(url); + expect(sleep).toHaveBeenCalledTimes(15); + expect(checkpoint).toHaveBeenCalledTimes(expectedCheckpoints); + expect(loader.requestCount).toBe(4); + }, + ); it("skips a foreign host after 429, caches failures, and continues healthy items", async () => { const seed = await seedContextJob(); await gateReplies(seed); diff --git a/src/federation/replies-worker.ts b/src/federation/replies-worker.ts index 34d7b048..ffc51078 100644 --- a/src/federation/replies-worker.ts +++ b/src/federation/replies-worker.ts @@ -204,6 +204,10 @@ export async function processRemoteReplyScrapeJob( ), clock, checkpoint, + checkpointIntervalMilliseconds: Math.max( + 1, + Math.min(30_000, (staleSeconds * 1000) / 3), + ), sleep: options.sleep ?? ((ms) => sleep(ms, options.signal)), signal: options.signal, }); From 85efe3c390ac6b9e46029ec11eb07c84dca49118 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 9 Oct 2026 14:20:17 +0900 Subject: [PATCH 4/4] Format changelog reference definitions Sort the FEP reference definitions with Hongdown so the Markdown check passes for the backfill changelog entry. This fixes the existing PR check failure without changing the release notes. https://github.com/fedify-dev/hollo/pull/664 Assisted-by: Codex:gpt-6.1-sol --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 2c457d1f..efe81a5f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -146,9 +146,9 @@ To be released. 14 adds post-quantum ML-DSA passkey support on runtimes that provide the algorithms. [[GHSA-2g3p-m8c9-hhwh], [GHSA-j3h4-m3m2-7p7j]] -[FEP-f228]: https://w3id.org/fep/f228 [FEP-044f]: https://w3id.org/fep/044f [FEP-c0e0]: https://w3id.org/fep/c0e0 +[FEP-f228]: https://w3id.org/fep/f228 [Gukhanmun]: https://gukhanmun.org/ [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749#section-3.3 [GHSA-2g3p-m8c9-hhwh]: https://github.com/MasterKale/SimpleWebAuthn/security/advisories/GHSA-2g3p-m8c9-hhwh