diff --git a/CHANGES.md b/CHANGES.md index 0efa7fc5..e8539f6a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -108,6 +108,10 @@ To be released. `410 Gone`, preventing repeated delivery retries for actors that have already been permanently deleted. + - Optimized follower-only status visibility checks by preloading approved + follow relationships and reusing simple `WHERE IN` conditions for status, + conversation context, quote, and timeline queries. [[#173], [#448]] + - Added `FEDIFY_DEBUG` environment variable to enable the [Fedify debugger], an embedded real-time dashboard for inspecting ActivityPub traces and activities. When enabled, the debug dashboard is accessible at @@ -149,6 +153,7 @@ To be released. - Upgraded Fedify to 2.1.7. +[#173]: https://github.com/fedify-dev/hollo/issues/173 [#348]: https://github.com/fedify-dev/hollo/issues/348 [#350]: https://github.com/fedify-dev/hollo/issues/350 [#357]: https://github.com/fedify-dev/hollo/issues/357 @@ -160,6 +165,7 @@ To be released. [#436]: https://github.com/fedify-dev/hollo/pull/436 [#445]: https://github.com/fedify-dev/hollo/issues/445 [#447]: https://github.com/fedify-dev/hollo/pull/447 +[#448]: https://github.com/fedify-dev/hollo/pull/448 [Fedify debugger]: https://fedify.dev/manual/debug diff --git a/drizzle/0085_optimize_follower_visibility.sql b/drizzle/0085_optimize_follower_visibility.sql new file mode 100644 index 00000000..59c2330e --- /dev/null +++ b/drizzle/0085_optimize_follower_visibility.sql @@ -0,0 +1 @@ +CREATE INDEX "follows_follower_id_following_id_approved_index" ON "follows" USING btree ("follower_id","following_id") WHERE "follows"."approved" is not null; \ No newline at end of file diff --git a/drizzle/meta/0085_snapshot.json b/drizzle/meta/0085_snapshot.json new file mode 100644 index 00000000..675adf03 --- /dev/null +++ b/drizzle/meta/0085_snapshot.json @@ -0,0 +1,4393 @@ +{ + "id": "9cd821ad-3da6-465b-9b7f-1589a7952b92", + "prevId": "13cbabb4-8a61-4c71-81c4-d6a63c1da623", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_grants": { + "name": "access_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "scope[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_owner_id": { + "name": "resource_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_grants_resource_owner_id_index": { + "name": "access_grants_resource_owner_id_index", + "columns": [ + { + "expression": "resource_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_grants_application_id_applications_id_fk": { + "name": "access_grants_application_id_applications_id_fk", + "tableFrom": "access_grants", + "tableTo": "applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_grants_resource_owner_id_account_owners_id_fk": { + "name": "access_grants_resource_owner_id_account_owners_id_fk", + "tableFrom": "access_grants", + "tableTo": "account_owners", + "columnsFrom": [ + "resource_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "access_grants_code_unique": { + "name": "access_grants_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_tokens": { + "name": "access_tokens", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "grant_type": { + "name": "grant_type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'authorization_code'" + }, + "scopes": { + "name": "scopes", + "type": "scope[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "access_tokens_application_id_applications_id_fk": { + "name": "access_tokens_application_id_applications_id_fk", + "tableFrom": "access_tokens", + "tableTo": "applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_tokens_account_owner_id_account_owners_id_fk": { + "name": "access_tokens_account_owner_id_account_owners_id_fk", + "tableFrom": "access_tokens", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_owners": { + "name": "account_owners", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rsa_private_key_jwk": { + "name": "rsa_private_key_jwk", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rsa_public_key_jwk": { + "name": "rsa_public_key_jwk", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ed25519_private_key_jwk": { + "name": "ed25519_private_key_jwk", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ed25519_public_key_jwk": { + "name": "ed25519_public_key_jwk", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fields": { + "name": "fields", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "followed_tags": { + "name": "followed_tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "visibility": { + "name": "visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "discoverable": { + "name": "discoverable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expand_spoilers": { + "name": "expand_spoilers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "theme_color": { + "name": "theme_color", + "type": "theme_color", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_owners_id_accounts_id_fk": { + "name": "account_owners_id_accounts_id_fk", + "tableFrom": "account_owners", + "tableTo": "accounts", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_owners_handle_unique": { + "name": "account_owners_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio_html": { + "name": "bio_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected": { + "name": "protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_url": { + "name": "inbox_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followers_url": { + "name": "followers_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_inbox_url": { + "name": "shared_inbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "featured_url": { + "name": "featured_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "following_count": { + "name": "following_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "followers_count": { + "name": "followers_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "posts_count": { + "name": "posts_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "field_htmls": { + "name": "field_htmls", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "emojis": { + "name": "emojis", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "successor_id": { + "name": "successor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "(ARRAY[]::text[])" + }, + "instance_host": { + "name": "instance_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "fetched": { + "name": "fetched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_successor_id_accounts_id_fk": { + "name": "accounts_successor_id_accounts_id_fk", + "tableFrom": "accounts", + "tableTo": "accounts", + "columnsFrom": [ + "successor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "accounts_instance_host_instances_host_fk": { + "name": "accounts_instance_host_instances_host_fk", + "tableFrom": "accounts", + "tableTo": "instances", + "columnsFrom": [ + "instance_host" + ], + "columnsTo": [ + "host" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_iri_unique": { + "name": "accounts_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + }, + "accounts_handle_unique": { + "name": "accounts_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.applications": { + "name": "applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "scope[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confidential": { + "name": "confidential", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "applications_client_id_unique": { + "name": "applications_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocks": { + "name": "blocks", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_account_id": { + "name": "blocked_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "blocks_account_id_index": { + "name": "blocks_account_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blocks_blocked_account_id_index": { + "name": "blocks_blocked_account_id_index", + "columns": [ + { + "expression": "blocked_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocks_account_id_accounts_id_fk": { + "name": "blocks_account_id_accounts_id_fk", + "tableFrom": "blocks", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocks_blocked_account_id_accounts_id_fk": { + "name": "blocks_blocked_account_id_accounts_id_fk", + "tableFrom": "blocks", + "tableTo": "accounts", + "columnsFrom": [ + "blocked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blocks_account_id_blocked_account_id_pk": { + "name": "blocks_account_id_blocked_account_id_pk", + "columns": [ + "account_id", + "blocked_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookmarks": { + "name": "bookmarks", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "bookmarks_post_id_account_owner_id_index": { + "name": "bookmarks_post_id_account_owner_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookmarks_post_id_posts_id_fk": { + "name": "bookmarks_post_id_posts_id_fk", + "tableFrom": "bookmarks", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarks_account_owner_id_account_owners_id_fk": { + "name": "bookmarks_account_owner_id_account_owners_id_fk", + "tableFrom": "bookmarks", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarks_post_id_account_owner_id_pk": { + "name": "bookmarks_post_id_account_owner_id_pk", + "columns": [ + "post_id", + "account_owner_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cleanup_job_items": { + "name": "cleanup_job_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "cleanup_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "cleanup_job_items_job_id_status_index": { + "name": "cleanup_job_items_job_id_status_index", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cleanup_job_items_job_id_cleanup_jobs_id_fk": { + "name": "cleanup_job_items_job_id_cleanup_jobs_id_fk", + "tableFrom": "cleanup_job_items", + "tableTo": "cleanup_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cleanup_jobs": { + "name": "cleanup_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "category": { + "name": "category", + "type": "cleanup_job_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "cleanup_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "total_items": { + "name": "total_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processed_items": { + "name": "processed_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "successful_items": { + "name": "successful_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_items": { + "name": "failed_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cleanup_jobs_status_created_index": { + "name": "cleanup_jobs_status_created_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "varchar(254)", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_emojis": { + "name": "custom_emojis", + "schema": "", + "columns": { + "shortcode": { + "name": "shortcode", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.featured_tags": { + "name": "featured_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "featured_tags_account_owner_id_account_owners_id_fk": { + "name": "featured_tags_account_owner_id_account_owners_id_fk", + "tableFrom": "featured_tags", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "featured_tags_account_owner_id_name_unique": { + "name": "featured_tags_account_owner_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "account_owner_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follows": { + "name": "follows", + "schema": "", + "columns": { + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "follower_id": { + "name": "follower_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shares": { + "name": "shares", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify": { + "name": "notify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "languages": { + "name": "languages", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "approved": { + "name": "approved", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "follows_following_id_approved_index": { + "name": "follows_following_id_approved_index", + "columns": [ + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "approved", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"follows\".\"approved\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "follows_follower_id_following_id_approved_index": { + "name": "follows_follower_id_following_id_approved_index", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"follows\".\"approved\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "follows_following_id_created_index": { + "name": "follows_following_id_created_index", + "columns": [ + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follows_following_id_accounts_id_fk": { + "name": "follows_following_id_accounts_id_fk", + "tableFrom": "follows", + "tableTo": "accounts", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_follower_id_accounts_id_fk": { + "name": "follows_follower_id_accounts_id_fk", + "tableFrom": "follows", + "tableTo": "accounts", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "follows_following_id_follower_id_pk": { + "name": "follows_following_id_follower_id_pk", + "columns": [ + "following_id", + "follower_id" + ] + } + }, + "uniqueConstraints": { + "follows_iri_unique": { + "name": "follows_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + } + }, + "policies": {}, + "checkConstraints": { + "ck_follows_self": { + "name": "ck_follows_self", + "value": "\"follows\".\"following_id\" != \"follows\".\"follower_id\"" + } + }, + "isRLSEnabled": false + }, + "public.import_job_items": { + "name": "import_job_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "import_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "import_job_items_job_id_status_index": { + "name": "import_job_items_job_id_status_index", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "import_job_items_job_id_import_jobs_id_fk": { + "name": "import_job_items_job_id_import_jobs_id_fk", + "tableFrom": "import_job_items", + "tableTo": "import_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.import_jobs": { + "name": "import_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "import_job_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "import_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "total_items": { + "name": "total_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processed_items": { + "name": "processed_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "successful_items": { + "name": "successful_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_items": { + "name": "failed_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "import_jobs_account_owner_id_status_index": { + "name": "import_jobs_account_owner_id_status_index", + "columns": [ + { + "expression": "account_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "import_jobs_status_created_index": { + "name": "import_jobs_status_created_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "import_jobs_account_owner_id_account_owners_id_fk": { + "name": "import_jobs_account_owner_id_account_owners_id_fk", + "tableFrom": "import_jobs", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instances": { + "name": "instances", + "schema": "", + "columns": { + "host": { + "name": "host", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "software": { + "name": "software", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.likes": { + "name": "likes", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "likes_account_id_post_id_index": { + "name": "likes_account_id_post_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "likes_created_index": { + "name": "likes_created_index", + "columns": [ + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "likes_post_id_posts_id_fk": { + "name": "likes_post_id_posts_id_fk", + "tableFrom": "likes", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "likes_account_id_accounts_id_fk": { + "name": "likes_account_id_accounts_id_fk", + "tableFrom": "likes", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "likes_post_id_account_id_pk": { + "name": "likes_post_id_account_id_pk", + "columns": [ + "post_id", + "account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.list_members": { + "name": "list_members", + "schema": "", + "columns": { + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "list_members_list_id_lists_id_fk": { + "name": "list_members_list_id_lists_id_fk", + "tableFrom": "list_members", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_members_account_id_accounts_id_fk": { + "name": "list_members_account_id_accounts_id_fk", + "tableFrom": "list_members", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_members_list_id_account_id_pk": { + "name": "list_members_list_id_account_id_pk", + "columns": [ + "list_id", + "account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.list_posts": { + "name": "list_posts", + "schema": "", + "columns": { + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "list_posts_list_id_post_id_index": { + "name": "list_posts_list_id_post_id_index", + "columns": [ + { + "expression": "list_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "list_posts_list_id_lists_id_fk": { + "name": "list_posts_list_id_lists_id_fk", + "tableFrom": "list_posts", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_posts_post_id_posts_id_fk": { + "name": "list_posts_post_id_posts_id_fk", + "tableFrom": "list_posts", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_posts_list_id_post_id_pk": { + "name": "list_posts_list_id_post_id_pk", + "columns": [ + "list_id", + "post_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lists": { + "name": "lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replies_policy": { + "name": "replies_policy", + "type": "list_replies_policy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'list'" + }, + "exclusive": { + "name": "exclusive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "lists_account_owner_id_account_owners_id_fk": { + "name": "lists_account_owner_id_account_owners_id_fk", + "tableFrom": "lists", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.markers": { + "name": "markers", + "schema": "", + "columns": { + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "marker_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "last_read_id": { + "name": "last_read_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "markers_account_owner_id_account_owners_id_fk": { + "name": "markers_account_owner_id_account_owners_id_fk", + "tableFrom": "markers", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "markers_account_owner_id_type_pk": { + "name": "markers_account_owner_id_type_pk", + "columns": [ + "account_owner_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_type": { + "name": "thumbnail_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumbnail_width": { + "name": "thumbnail_width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "thumbnail_height": { + "name": "thumbnail_height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "thumbnail_cleaned": { + "name": "thumbnail_cleaned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "media_post_id_index": { + "name": "media_post_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_post_id_posts_id_fk": { + "name": "media_post_id_posts_id_fk", + "tableFrom": "media", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mentions": { + "name": "mentions", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mentions_post_id_account_id_index": { + "name": "mentions_post_id_account_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mentions_post_id_posts_id_fk": { + "name": "mentions_post_id_posts_id_fk", + "tableFrom": "mentions", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mentions_account_id_accounts_id_fk": { + "name": "mentions_account_id_accounts_id_fk", + "tableFrom": "mentions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mentions_post_id_account_id_pk": { + "name": "mentions_post_id_account_id_pk", + "columns": [ + "post_id", + "account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mutes": { + "name": "mutes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "muted_account_id": { + "name": "muted_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notifications": { + "name": "notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "duration": { + "name": "duration", + "type": "interval", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "mutes_account_id_index": { + "name": "mutes_account_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mutes_account_id_accounts_id_fk": { + "name": "mutes_account_id_accounts_id_fk", + "tableFrom": "mutes", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mutes_muted_account_id_accounts_id_fk": { + "name": "mutes_muted_account_id_accounts_id_fk", + "tableFrom": "mutes", + "tableTo": "accounts", + "columnsFrom": [ + "muted_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mutes_account_id_muted_account_id_unique": { + "name": "mutes_account_id_muted_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "account_id", + "muted_account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_groups": { + "name": "notification_groups", + "schema": "", + "columns": { + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_post_id": { + "name": "target_post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "notifications_count": { + "name": "notifications_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "most_recent_notification_id": { + "name": "most_recent_notification_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sample_account_ids": { + "name": "sample_account_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::uuid[]" + }, + "latest_page_notification_at": { + "name": "latest_page_notification_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "page_min_id": { + "name": "page_min_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "page_max_id": { + "name": "page_max_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "notification_groups_account_owner_id_updated_index": { + "name": "notification_groups_account_owner_id_updated_index", + "columns": [ + { + "expression": "account_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_groups_account_owner_id_type_index": { + "name": "notification_groups_account_owner_id_type_index", + "columns": [ + { + "expression": "account_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_groups_account_owner_id_account_owners_id_fk": { + "name": "notification_groups_account_owner_id_account_owners_id_fk", + "tableFrom": "notification_groups", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_groups_target_post_id_posts_id_fk": { + "name": "notification_groups_target_post_id_posts_id_fk", + "tableFrom": "notification_groups", + "tableTo": "posts", + "columnsFrom": [ + "target_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_groups_most_recent_notification_id_notifications_id_fk": { + "name": "notification_groups_most_recent_notification_id_notifications_id_fk", + "tableFrom": "notification_groups", + "tableTo": "notifications", + "columnsFrom": [ + "most_recent_notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_owner_id": { + "name": "account_owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_account_id": { + "name": "actor_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_post_id": { + "name": "target_post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_account_id": { + "name": "target_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_poll_id": { + "name": "target_poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "notifications_account_owner_id_created_index": { + "name": "notifications_account_owner_id_created_index", + "columns": [ + { + "expression": "account_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_account_owner_id_read_at_index": { + "name": "notifications_account_owner_id_read_at_index", + "columns": [ + { + "expression": "account_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_group_key_index": { + "name": "notifications_group_key_index", + "columns": [ + { + "expression": "group_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_index": { + "name": "notifications_created_index", + "columns": [ + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_account_owner_id_account_owners_id_fk": { + "name": "notifications_account_owner_id_account_owners_id_fk", + "tableFrom": "notifications", + "tableTo": "account_owners", + "columnsFrom": [ + "account_owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_actor_account_id_accounts_id_fk": { + "name": "notifications_actor_account_id_accounts_id_fk", + "tableFrom": "notifications", + "tableTo": "accounts", + "columnsFrom": [ + "actor_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_target_post_id_posts_id_fk": { + "name": "notifications_target_post_id_posts_id_fk", + "tableFrom": "notifications", + "tableTo": "posts", + "columnsFrom": [ + "target_post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_target_account_id_accounts_id_fk": { + "name": "notifications_target_account_id_accounts_id_fk", + "tableFrom": "notifications", + "tableTo": "accounts", + "columnsFrom": [ + "target_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_target_poll_id_polls_id_fk": { + "name": "notifications_target_poll_id_polls_id_fk", + "tableFrom": "notifications", + "tableTo": "polls", + "columnsFrom": [ + "target_poll_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_posts": { + "name": "pinned_posts", + "schema": "", + "columns": { + "index": { + "name": "index", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pinned_posts_account_id_post_id_index": { + "name": "pinned_posts_account_id_post_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_posts_account_id_accounts_id_fk": { + "name": "pinned_posts_account_id_accounts_id_fk", + "tableFrom": "pinned_posts", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_posts_post_id_account_id_posts_id_actor_id_fk": { + "name": "pinned_posts_post_id_account_id_posts_id_actor_id_fk", + "tableFrom": "pinned_posts", + "tableTo": "posts", + "columnsFrom": [ + "post_id", + "account_id" + ], + "columnsTo": [ + "id", + "actor_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pinned_posts_post_id_account_id_unique": { + "name": "pinned_posts_post_id_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "post_id", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_options": { + "name": "poll_options", + "schema": "", + "columns": { + "poll_id": { + "name": "poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "votes_count": { + "name": "votes_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "poll_options_poll_id_index_index": { + "name": "poll_options_poll_id_index_index", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "poll_options_poll_id_polls_id_fk": { + "name": "poll_options_poll_id_polls_id_fk", + "tableFrom": "poll_options", + "tableTo": "polls", + "columnsFrom": [ + "poll_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "poll_options_poll_id_index_pk": { + "name": "poll_options_poll_id_index_pk", + "columns": [ + "poll_id", + "index" + ] + } + }, + "uniqueConstraints": { + "poll_options_poll_id_title_unique": { + "name": "poll_options_poll_id_title_unique", + "nullsNotDistinct": false, + "columns": [ + "poll_id", + "title" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.poll_votes": { + "name": "poll_votes", + "schema": "", + "columns": { + "poll_id": { + "name": "poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "option_index": { + "name": "option_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "poll_votes_poll_id_account_id_index": { + "name": "poll_votes_poll_id_account_id_index", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "poll_votes_poll_id_polls_id_fk": { + "name": "poll_votes_poll_id_polls_id_fk", + "tableFrom": "poll_votes", + "tableTo": "polls", + "columnsFrom": [ + "poll_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "poll_votes_account_id_accounts_id_fk": { + "name": "poll_votes_account_id_accounts_id_fk", + "tableFrom": "poll_votes", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "poll_votes_poll_id_option_index_poll_options_poll_id_index_fk": { + "name": "poll_votes_poll_id_option_index_poll_options_poll_id_index_fk", + "tableFrom": "poll_votes", + "tableTo": "poll_options", + "columnsFrom": [ + "poll_id", + "option_index" + ], + "columnsTo": [ + "poll_id", + "index" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "poll_votes_poll_id_option_index_account_id_pk": { + "name": "poll_votes_poll_id_option_index_account_id_pk", + "columns": [ + "poll_id", + "option_index", + "account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.polls": { + "name": "polls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "multiple": { + "name": "multiple", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "voters_count": { + "name": "voters_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reply_target_id": { + "name": "reply_target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sharing_id": { + "name": "sharing_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quote_target_id": { + "name": "quote_target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "post_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_html": { + "name": "content_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poll_id": { + "name": "poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "emojis": { + "name": "emojis", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_card": { + "name": "preview_card", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replies_count": { + "name": "replies_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "shares_count": { + "name": "shares_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "likes_count": { + "name": "likes_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "quotes_count": { + "name": "quotes_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "idempotence_key": { + "name": "idempotence_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "posts_sharing_id_index": { + "name": "posts_sharing_id_index", + "columns": [ + { + "expression": "sharing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_actor_id_index": { + "name": "posts_actor_id_index", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_actor_id_sharing_id_index": { + "name": "posts_actor_id_sharing_id_index", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sharing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_reply_target_id_index": { + "name": "posts_reply_target_id_index", + "columns": [ + { + "expression": "reply_target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_actor_id_reply_target_id_index": { + "name": "posts_actor_id_reply_target_id_index", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reply_target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_quote_target_id_index": { + "name": "posts_quote_target_id_index", + "columns": [ + { + "expression": "quote_target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"posts\".\"quote_target_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_visibility_actor_id_index": { + "name": "posts_visibility_actor_id_index", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_visibility_actor_id_sharing_id_index": { + "name": "posts_visibility_actor_id_sharing_id_index", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sharing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"posts\".\"sharing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_visibility_actor_id_reply_target_id_index": { + "name": "posts_visibility_actor_id_reply_target_id_index", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reply_target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"posts\".\"reply_target_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_actor_id_accounts_id_fk": { + "name": "posts_actor_id_accounts_id_fk", + "tableFrom": "posts", + "tableTo": "accounts", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_application_id_applications_id_fk": { + "name": "posts_application_id_applications_id_fk", + "tableFrom": "posts", + "tableTo": "applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "posts_reply_target_id_posts_id_fk": { + "name": "posts_reply_target_id_posts_id_fk", + "tableFrom": "posts", + "tableTo": "posts", + "columnsFrom": [ + "reply_target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "posts_sharing_id_posts_id_fk": { + "name": "posts_sharing_id_posts_id_fk", + "tableFrom": "posts", + "tableTo": "posts", + "columnsFrom": [ + "sharing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_quote_target_id_posts_id_fk": { + "name": "posts_quote_target_id_posts_id_fk", + "tableFrom": "posts", + "tableTo": "posts", + "columnsFrom": [ + "quote_target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "posts_poll_id_polls_id_fk": { + "name": "posts_poll_id_polls_id_fk", + "tableFrom": "posts", + "tableTo": "polls", + "columnsFrom": [ + "poll_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "posts_iri_unique": { + "name": "posts_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + }, + "posts_id_actor_id_unique": { + "name": "posts_id_actor_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "actor_id" + ] + }, + "posts_poll_id_unique": { + "name": "posts_poll_id_unique", + "nullsNotDistinct": false, + "columns": [ + "poll_id" + ] + }, + "posts_actor_id_sharing_id_unique": { + "name": "posts_actor_id_sharing_id_unique", + "nullsNotDistinct": false, + "columns": [ + "actor_id", + "sharing_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "custom_emoji": { + "name": "custom_emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emoji_iri": { + "name": "emoji_iri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "reactions_post_id_index": { + "name": "reactions_post_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_post_id_account_id_index": { + "name": "reactions_post_id_account_id_index", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_created_index": { + "name": "reactions_created_index", + "columns": [ + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reactions_post_id_posts_id_fk": { + "name": "reactions_post_id_posts_id_fk", + "tableFrom": "reactions", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reactions_account_id_accounts_id_fk": { + "name": "reactions_account_id_accounts_id_fk", + "tableFrom": "reactions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reactions_post_id_account_id_emoji_pk": { + "name": "reactions_post_id_account_id_emoji_pk", + "columns": [ + "post_id", + "account_id", + "emoji" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.remote_reply_scrape_jobs": { + "name": "remote_reply_scrape_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_iri": { + "name": "post_iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replies_iri": { + "name": "replies_iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_host": { + "name": "origin_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "remote_reply_scrape_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fetched_items": { + "name": "fetched_items", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "remote_reply_scrape_jobs_claim_index": { + "name": "remote_reply_scrape_jobs_claim_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_reply_scrape_jobs_origin_claim_index": { + "name": "remote_reply_scrape_jobs_origin_claim_index", + "columns": [ + { + "expression": "origin_host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_reply_scrape_jobs_stale_processing_index": { + "name": "remote_reply_scrape_jobs_stale_processing_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "remote_reply_scrape_jobs_post_id_posts_id_fk": { + "name": "remote_reply_scrape_jobs_post_id_posts_id_fk", + "tableFrom": "remote_reply_scrape_jobs", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "remote_reply_scrape_jobs_replies_iri_unique": { + "name": "remote_reply_scrape_jobs_replies_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "replies_iri" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.remote_reply_scrape_origins": { + "name": "remote_reply_scrape_origins", + "schema": "", + "columns": { + "origin_host": { + "name": "origin_host", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "next_request_at": { + "name": "next_request_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processing_job_id": { + "name": "processing_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated": { + "name": "updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "remote_reply_scrape_origins_next_request_at_index": { + "name": "remote_reply_scrape_origins_next_request_at_index", + "columns": [ + { + "expression": "next_request_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "remote_reply_scrape_origins_processing_job_id_index": { + "name": "remote_reply_scrape_origins_processing_job_id_index", + "columns": [ + { + "expression": "processing_job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "iri": { + "name": "iri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_account_id": { + "name": "target_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "posts": { + "name": "posts", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::uuid[]" + } + }, + "indexes": {}, + "foreignKeys": { + "reports_account_id_accounts_id_fk": { + "name": "reports_account_id_accounts_id_fk", + "tableFrom": "reports", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_target_account_id_accounts_id_fk": { + "name": "reports_target_account_id_accounts_id_fk", + "tableFrom": "reports", + "tableTo": "accounts", + "columnsFrom": [ + "target_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reports_iri_unique": { + "name": "reports_iri_unique", + "nullsNotDistinct": false, + "columns": [ + "iri" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.timeline_posts": { + "name": "timeline_posts", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_posts_account_id_post_id_index": { + "name": "timeline_posts_account_id_post_id_index", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "timeline_posts_account_id_account_owners_id_fk": { + "name": "timeline_posts_account_id_account_owners_id_fk", + "tableFrom": "timeline_posts", + "tableTo": "account_owners", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "timeline_posts_post_id_posts_id_fk": { + "name": "timeline_posts_post_id_posts_id_fk", + "tableFrom": "timeline_posts", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "timeline_posts_account_id_post_id_pk": { + "name": "timeline_posts_account_id_post_id_pk", + "columns": [ + "account_id", + "post_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.totps": { + "name": "totps", + "schema": "", + "columns": { + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "digits": { + "name": "digits", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_type": { + "name": "account_type", + "schema": "public", + "values": [ + "Application", + "Group", + "Organization", + "Person", + "Service" + ] + }, + "public.cleanup_job_category": { + "name": "cleanup_job_category", + "schema": "public", + "values": [ + "cleanup_thumbnails" + ] + }, + "public.cleanup_job_status": { + "name": "cleanup_job_status", + "schema": "public", + "values": [ + "pending", + "processing", + "completed", + "failed", + "cancelled" + ] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "authorization_code", + "client_credentials" + ] + }, + "public.import_job_category": { + "name": "import_job_category", + "schema": "public", + "values": [ + "following_accounts", + "lists", + "muted_accounts", + "blocked_accounts", + "bookmarks" + ] + }, + "public.import_job_status": { + "name": "import_job_status", + "schema": "public", + "values": [ + "pending", + "processing", + "completed", + "failed", + "cancelled" + ] + }, + "public.list_replies_policy": { + "name": "list_replies_policy", + "schema": "public", + "values": [ + "followed", + "list", + "none" + ] + }, + "public.marker_type": { + "name": "marker_type", + "schema": "public", + "values": [ + "notifications", + "home" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "mention", + "status", + "reblog", + "follow", + "follow_request", + "favourite", + "emoji_reaction", + "poll", + "update", + "admin.sign_up", + "admin.report", + "quote", + "quoted_update" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": [ + "Article", + "Note", + "Question" + ] + }, + "public.post_visibility": { + "name": "post_visibility", + "schema": "public", + "values": [ + "public", + "unlisted", + "private", + "direct" + ] + }, + "public.remote_reply_scrape_job_status": { + "name": "remote_reply_scrape_job_status", + "schema": "public", + "values": [ + "pending", + "processing", + "completed", + "failed" + ] + }, + "public.scope": { + "name": "scope", + "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" + ] + }, + "public.theme_color": { + "name": "theme_color", + "schema": "public", + "values": [ + "amber", + "azure", + "blue", + "cyan", + "fuchsia", + "green", + "grey", + "indigo", + "jade", + "lime", + "orange", + "pink", + "pumpkin", + "purple", + "red", + "sand", + "slate", + "violet", + "yellow", + "zinc" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8268915c..e098160e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -596,6 +596,13 @@ "when": 1777177569926, "tag": "0084_remote_reply_scrape_stale_index", "breakpoints": true + }, + { + "idx": 85, + "version": "7", + "when": 1777191395619, + "tag": "0085_optimize_follower_visibility", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/api/v1/statuses.test.ts b/src/api/v1/statuses.test.ts index 233d6836..18b099a2 100644 --- a/src/api/v1/statuses.test.ts +++ b/src/api/v1/statuses.test.ts @@ -8,7 +8,10 @@ import { getAccessToken, getApplication, } from "../../../tests/helpers/oauth"; +import db from "../../db"; import app from "../../index"; +import { follows, posts } from "../../schema"; +import { uuidv7 } from "../../uuid"; describe.sequential("/api/v1/accounts/verify_credentials", () => { let client: Awaited>; @@ -210,3 +213,141 @@ describe.sequential("/api/v1/accounts/verify_credentials", () => { expect(json.poll).toBeNull(); // This one stays null as expected }); }); + +describe.sequential("/api/v1/statuses visibility", () => { + let viewer: Awaited>; + let approvedAuthor: Awaited>; + let pendingAuthor: Awaited>; + let client: Awaited>; + let accessToken: Awaited>; + + beforeEach(async () => { + await cleanDatabase(); + + viewer = await createAccount({ username: "viewer" }); + approvedAuthor = await createAccount({ username: "approved-author" }); + pendingAuthor = await createAccount({ username: "pending-author" }); + client = await createOAuthApplication({ + scopes: ["read:statuses"], + }); + accessToken = await getAccessToken(client, viewer, ["read:statuses"]); + + await db.insert(follows).values([ + { + iri: `https://hollo.test/follows/${crypto.randomUUID()}`, + followingId: approvedAuthor.id, + followerId: viewer.id, + approved: new Date(), + }, + { + iri: `https://hollo.test/follows/${crypto.randomUUID()}`, + followingId: pendingAuthor.id, + followerId: viewer.id, + approved: null, + }, + ]); + }); + + it("allows private statuses from approved follows only", async () => { + expect.assertions(4); + + const approvedPostId = uuidv7(); + const pendingPostId = uuidv7(); + + await db.insert(posts).values([ + { + id: approvedPostId, + iri: `https://hollo.test/@approved-author/${approvedPostId}`, + type: "Note", + accountId: approvedAuthor.id, + visibility: "private", + content: "Approved followers-only post", + contentHtml: "

Approved followers-only post

", + published: new Date(), + }, + { + id: pendingPostId, + iri: `https://hollo.test/@pending-author/${pendingPostId}`, + type: "Note", + accountId: pendingAuthor.id, + visibility: "private", + content: "Pending followers-only post", + contentHtml: "

Pending followers-only post

", + published: new Date(), + }, + ]); + + const approvedResponse = await app.request( + `/api/v1/statuses/${approvedPostId}`, + { + headers: { + authorization: bearerAuthorization(accessToken), + }, + }, + ); + const pendingResponse = await app.request( + `/api/v1/statuses/${pendingPostId}`, + { + headers: { + authorization: bearerAuthorization(accessToken), + }, + }, + ); + + expect(approvedResponse.status).toBe(200); + expect(pendingResponse.status).toBe(404); + + const json = await approvedResponse.json(); + + expect(json.id).toBe(approvedPostId); + expect(json.visibility).toBe("private"); + }); + + it("includes private ancestors from approved follows in status context", async () => { + expect.assertions(4); + + const ancestorPostId = uuidv7(); + const childPostId = uuidv7(); + + await db.insert(posts).values([ + { + id: ancestorPostId, + iri: `https://hollo.test/@approved-author/${ancestorPostId}`, + type: "Note", + accountId: approvedAuthor.id, + visibility: "private", + content: "Private ancestor", + contentHtml: "

Private ancestor

", + published: new Date(), + }, + { + id: childPostId, + iri: `https://hollo.test/@approved-author/${childPostId}`, + type: "Note", + accountId: approvedAuthor.id, + replyTargetId: ancestorPostId, + visibility: "public", + content: "Public reply", + contentHtml: "

Public reply

", + published: new Date(), + }, + ]); + + const response = await app.request( + `/api/v1/statuses/${childPostId}/context`, + { + headers: { + authorization: bearerAuthorization(accessToken), + }, + }, + ); + + expect(response.status).toBe(200); + + const json = await response.json(); + + expect(json.ancestors).toHaveLength(1); + expect(json.ancestors[0].id).toBe(ancestorPostId); + expect(json.descendants).toHaveLength(0); + }); +}); diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index 7bec4535..e26de422 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -14,9 +14,7 @@ import { and, desc, eq, - exists, gt, - inArray, isNotNull, isNull, lt, @@ -57,7 +55,6 @@ import { blocks, bookmarks, customEmojis, - follows, type Like, likes, type Mention, @@ -77,6 +74,10 @@ import { reactions, } from "../../schema"; import { isUuid, type Uuid, uuid, uuidv7 } from "../../uuid"; +import { + buildPostVisibilityConditions, + getPostVisibilityScope, +} from "../visibility"; const app = new Hono<{ Variables: Variables }>(); const logger = getLogger(["hollo", "api", "v1", "statuses"]); @@ -97,63 +98,6 @@ function getReactionOrderingKey( return `react:${actorIri}:${postIri}:${emoji}`; } -/** - * Builds visibility conditions for post queries based on viewer's permissions. - * For unauthenticated users, only public/unlisted posts are visible. - * For authenticated users, includes private posts from accounts they follow, - * and direct posts where they are mentioned or are the author. - */ -function buildVisibilityConditions(viewerAccountId: Uuid | null | undefined) { - if (viewerAccountId == null) { - // Unauthenticated: only public and unlisted posts - return inArray(posts.visibility, ["public", "unlisted"]); - } - - // Authenticated: include private and direct posts based on relationships - return or( - inArray(posts.visibility, ["public", "unlisted"]), - and( - eq(posts.visibility, "private"), - or( - // User's own posts - eq(posts.accountId, viewerAccountId), - // Posts from accounts the user follows (approved follows only) - exists( - db - .select({ id: follows.followingId }) - .from(follows) - .where( - and( - eq(follows.followingId, posts.accountId), - eq(follows.followerId, viewerAccountId), - isNotNull(follows.approved), - ), - ), - ), - ), - ), - and( - inArray(posts.visibility, ["private", "direct"]), - or( - // User's own direct posts - eq(posts.accountId, viewerAccountId), - // Direct posts where the user is mentioned - exists( - db - .select({ postId: mentions.postId }) - .from(mentions) - .where( - and( - eq(mentions.postId, posts.id), - eq(mentions.accountId, viewerAccountId), - ), - ), - ), - ), - ), - ); -} - /** * Builds mute and block conditions for authenticated users. * Returns undefined for unauthenticated users (no mute/block filtering). @@ -550,8 +494,12 @@ app.get("/:id", async (c) => { if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); + const visibilityScope = await getPostVisibilityScope(owner?.id); const post = await db.query.posts.findFirst({ - where: and(eq(posts.id, id), buildVisibilityConditions(owner?.id)), + where: and( + eq(posts.id, id), + buildPostVisibilityConditions(visibilityScope), + ), with: getPostRelations(owner?.id), }); @@ -650,8 +598,12 @@ app.get("/:id/context", async (c) => { const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); + const visibilityScope = await getPostVisibilityScope(owner?.id); const post = await db.query.posts.findFirst({ - where: and(eq(posts.id, id), buildVisibilityConditions(owner?.id)), + where: and( + eq(posts.id, id), + buildPostVisibilityConditions(visibilityScope), + ), with: getPostRelations(owner?.id), }); if (post == null) return c.json({ error: "Record not found" }, 404); @@ -661,7 +613,7 @@ app.get("/:id/context", async (c) => { p = await db.query.posts.findFirst({ where: and( eq(posts.id, p.replyTargetId), - buildVisibilityConditions(owner?.id), + buildPostVisibilityConditions(visibilityScope), buildMuteAndBlockConditions(owner?.id), ), with: getPostRelations(owner?.id), @@ -677,7 +629,7 @@ app.get("/:id/context", async (c) => { const replies = await db.query.posts.findMany({ where: and( eq(posts.replyTargetId, p.id), - buildVisibilityConditions(owner?.id), + buildPostVisibilityConditions(visibilityScope), buildMuteAndBlockConditions(owner?.id), ), with: getPostRelations(owner?.id), @@ -1482,8 +1434,12 @@ app.get("/:id/quotes", async (c) => { const id = c.req.param("id"); if (!isUuid(id)) return c.json({ error: "Record not found" }, 404); + const visibilityScope = await getPostVisibilityScope(owner?.id); const post = await db.query.posts.findFirst({ - where: and(eq(posts.id, id), buildVisibilityConditions(owner?.id)), + where: and( + eq(posts.id, id), + buildPostVisibilityConditions(visibilityScope), + ), }); if (post == null) return c.json({ error: "Record not found" }, 404); @@ -1500,7 +1456,7 @@ app.get("/:id/quotes", async (c) => { where: and( eq(posts.quoteTargetId, id), isNull(posts.sharingId), - buildVisibilityConditions(owner?.id), + buildPostVisibilityConditions(visibilityScope), buildMuteAndBlockConditions(owner?.id), query.max_id != null ? lt(posts.id, query.max_id) : undefined, query.since_id != null ? gt(posts.id, query.since_id) : undefined, diff --git a/src/api/v1/timelines.test.ts b/src/api/v1/timelines.test.ts index e8a27180..04fe105a 100644 --- a/src/api/v1/timelines.test.ts +++ b/src/api/v1/timelines.test.ts @@ -11,6 +11,7 @@ import db from "../../db"; import app from "../../index"; import { accounts, + follows, instances, listMembers, lists, @@ -141,3 +142,83 @@ describe.sequential("/api/v1/timelines/list/:list_id", () => { expect(json[0].media_attachments[0].type).toBe("unknown"); }); }); + +describe.sequential("/api/v1/timelines/home", () => { + let owner: Awaited>; + let approvedAuthor: Awaited>; + let pendingAuthor: Awaited>; + let client: Awaited>; + let accessToken: Awaited>; + + beforeEach(async () => { + await cleanDatabase(); + + owner = await createAccount({ username: "timeline-owner" }); + approvedAuthor = await createAccount({ username: "timeline-approved" }); + pendingAuthor = await createAccount({ username: "timeline-pending" }); + client = await createOAuthApplication({ + scopes: ["read:statuses"], + }); + accessToken = await getAccessToken(client, owner, ["read:statuses"]); + + await db.insert(follows).values([ + { + iri: `https://hollo.test/follows/${crypto.randomUUID()}`, + followingId: approvedAuthor.id, + followerId: owner.id, + approved: new Date(), + }, + { + iri: `https://hollo.test/follows/${crypto.randomUUID()}`, + followingId: pendingAuthor.id, + followerId: owner.id, + approved: null, + }, + ]); + }); + + it("includes private posts from approved follows only", async () => { + expect.assertions(4); + + const approvedPostId = uuidv7(); + const pendingPostId = uuidv7(); + + await db.insert(posts).values([ + { + id: approvedPostId, + iri: `https://hollo.test/@timeline-approved/${approvedPostId}`, + type: "Note", + accountId: approvedAuthor.id, + visibility: "private", + content: "Approved timeline post", + contentHtml: "

Approved timeline post

", + published: new Date(), + }, + { + id: pendingPostId, + iri: `https://hollo.test/@timeline-pending/${pendingPostId}`, + type: "Note", + accountId: pendingAuthor.id, + visibility: "private", + content: "Pending timeline post", + contentHtml: "

Pending timeline post

", + published: new Date(), + }, + ]); + + const response = await app.request("/api/v1/timelines/home", { + headers: { + authorization: bearerAuthorization(accessToken), + }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const json = await response.json(); + const ids = json.map((status: { id: string }) => status.id); + + expect(json).toHaveLength(1); + expect(ids).toEqual([approvedPostId]); + }); +}); diff --git a/src/api/v1/timelines.ts b/src/api/v1/timelines.ts index 0c1bb1b0..48b07e97 100644 --- a/src/api/v1/timelines.ts +++ b/src/api/v1/timelines.ts @@ -31,7 +31,6 @@ import { import { accountOwners, blocks, - follows, listMembers, listPosts, lists, @@ -41,6 +40,10 @@ import { timelinePosts, } from "../../schema"; import { isUuid, uuid } from "../../uuid"; +import { + getApprovedFollowingAccountIds, + postAccountIdInArray, +} from "../visibility"; const app = new Hono<{ Variables: Variables }>(); @@ -323,6 +326,9 @@ app.get( limit: query.limit, }); } else { + const followingAccountIds = await getApprovedFollowingAccountIds( + owner.id, + ); const followedTags: SQL[] = owner.followedTags.map( // oxlint-disable-next-line prefer-template (t) => sql`${"#" + t}`, @@ -333,13 +339,7 @@ app.get( eq(posts.accountId, owner.id), and( ne(posts.visibility, "direct"), - inArray( - posts.accountId, - db - .select({ id: follows.followingId }) - .from(follows) - .where(eq(follows.followerId, owner.id)), - ), + postAccountIdInArray(followingAccountIds), notInArray( posts.accountId, db @@ -379,13 +379,7 @@ app.get( .where( or( eq(posts.accountId, owner.id), - inArray( - posts.accountId, - db - .select({ id: follows.followingId }) - .from(follows) - .where(eq(follows.followerId, owner.id)), - ), + postAccountIdInArray(followingAccountIds), ), ), ), @@ -627,6 +621,9 @@ app.get( limit: query.limit, }); } else { + const followingAccountIds = await getApprovedFollowingAccountIds( + owner.id, + ); timeline = await db.query.posts.findMany({ where: and( ne(posts.visibility, "direct"), @@ -650,13 +647,7 @@ app.get( or( eq(posts.accountId, owner.id), list.repliesPolicy === "followed" - ? inArray( - posts.accountId, - db - .select({ id: follows.followingId }) - .from(follows) - .where(eq(follows.followerId, owner.id)), - ) + ? postAccountIdInArray(followingAccountIds) : inArray( posts.accountId, db @@ -787,19 +778,14 @@ app.get( } const query = c.req.valid("query"); const hashtag = `#${c.req.param("hashtag")}`; + const followingAccountIds = await getApprovedFollowingAccountIds(owner.id); const timeline = await db.query.posts.findMany({ where: and( or( eq(posts.accountId, owner.id), and( ne(posts.visibility, "direct"), - inArray( - posts.accountId, - db - .select({ id: follows.followingId }) - .from(follows) - .where(eq(follows.followerId, owner.id)), - ), + postAccountIdInArray(followingAccountIds), ), and( ne(posts.visibility, "private"), diff --git a/src/api/visibility.test.ts b/src/api/visibility.test.ts new file mode 100644 index 00000000..e39e84dd --- /dev/null +++ b/src/api/visibility.test.ts @@ -0,0 +1,97 @@ +import { and, eq } from "drizzle-orm"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { cleanDatabase } from "../../tests/helpers"; +import { createAccount } from "../../tests/helpers/oauth"; +import db from "../db"; +import { accounts, follows, instances, posts } from "../schema"; +import type { Uuid } from "../uuid"; +import { uuidv7 } from "../uuid"; +import { + buildPostVisibilityConditions, + getApprovedFollowingAccountIds, +} from "./visibility"; + +async function createRemoteAccount(username: string): Promise { + const id = crypto.randomUUID() as Uuid; + + await db + .insert(instances) + .values({ host: "remote.test" }) + .onConflictDoNothing(); + + await db.insert(accounts).values({ + id, + iri: `https://remote.test/users/${username}`, + instanceHost: "remote.test", + type: "Person", + name: `Remote ${username}`, + emojis: {}, + handle: `@${username}@remote.test`, + bioHtml: "", + url: `https://remote.test/@${username}`, + protected: false, + inboxUrl: `https://remote.test/users/${username}/inbox`, + }); + + return id; +} + +describe.sequential("visibility helpers", () => { + beforeEach(async () => { + await cleanDatabase(); + }); + + it("returns approved following account IDs only", async () => { + expect.assertions(1); + + const viewer = await createAccount({ username: "viewer" }); + const approvedAuthorId = await createRemoteAccount("approved"); + const pendingAuthorId = await createRemoteAccount("pending"); + + await db.insert(follows).values([ + { + iri: `https://hollo.test/follows/${crypto.randomUUID()}`, + followingId: approvedAuthorId, + followerId: viewer.id, + approved: new Date(), + }, + { + iri: `https://hollo.test/follows/${crypto.randomUUID()}`, + followingId: pendingAuthorId, + followerId: viewer.id, + approved: null, + }, + ]); + + await expect(getApprovedFollowingAccountIds(viewer.id)).resolves.toEqual([ + approvedAuthorId, + ]); + }); + + it("builds post visibility SQL without a follows subquery", () => { + expect.assertions(5); + + const viewerId = uuidv7(); + const followedAccountId = uuidv7(); + const condition = buildPostVisibilityConditions({ + viewerAccountId: viewerId, + followingAccountIds: [followedAccountId], + }); + const query = db + .select() + .from(posts) + .where(and(eq(posts.id, uuidv7()), condition)) + .toSQL(); + + expect(query.sql).not.toContain('"follows"'); + expect(query.sql).toContain('"actor_id" = ANY'); + expect(query.params).not.toContain(followedAccountId); + expect(query.params).toContain(viewerId); + expect( + query.params.some((param) => + Array.isArray((param as { value?: unknown }).value), + ), + ).toBe(true); + }); +}); diff --git a/src/api/visibility.ts b/src/api/visibility.ts new file mode 100644 index 00000000..abbe2b32 --- /dev/null +++ b/src/api/visibility.ts @@ -0,0 +1,76 @@ +import { and, eq, exists, inArray, isNotNull, or, sql } from "drizzle-orm"; + +import { db, postgres } from "../db"; +import { follows, mentions, posts } from "../schema"; +import type { Uuid } from "../uuid"; + +// postgres-js needs the array type OID here; 2951 is uuid[]. +const UUID_ARRAY_OID = 2951; + +export type PostVisibilityScope = { + viewerAccountId: Uuid | null; + followingAccountIds: Uuid[]; +}; + +export async function getApprovedFollowingAccountIds( + accountId: Uuid, +): Promise { + const rows = await db + .select({ id: follows.followingId }) + .from(follows) + .where(and(eq(follows.followerId, accountId), isNotNull(follows.approved))); + + return rows.map((row) => row.id); +} + +export function postAccountIdInArray(accountIds: Uuid[]) { + return sql`${posts.accountId} = ANY(${postgres.array(accountIds, UUID_ARRAY_OID)})`; +} + +export async function getPostVisibilityScope( + viewerAccountId: Uuid | null | undefined, +): Promise { + if (viewerAccountId == null) { + return { viewerAccountId: null, followingAccountIds: [] }; + } + + return { + viewerAccountId, + followingAccountIds: await getApprovedFollowingAccountIds(viewerAccountId), + }; +} + +export function buildPostVisibilityConditions(scope: PostVisibilityScope) { + const { viewerAccountId } = scope; + + if (viewerAccountId == null) { + return inArray(posts.visibility, ["public", "unlisted"]); + } + + const privateAccountIds = [ + ...new Set([viewerAccountId, ...scope.followingAccountIds]), + ]; + const recipientCondition = or( + eq(posts.accountId, viewerAccountId), + exists( + db + .select({ postId: mentions.postId }) + .from(mentions) + .where( + and( + eq(mentions.postId, posts.id), + eq(mentions.accountId, viewerAccountId), + ), + ), + ), + ); + + return or( + inArray(posts.visibility, ["public", "unlisted"]), + and( + eq(posts.visibility, "private"), + or(postAccountIdInArray(privateAccountIds), recipientCondition), + ), + and(eq(posts.visibility, "direct"), recipientCondition), + ); +} diff --git a/src/federation/timeline.test.ts b/src/federation/timeline.test.ts index 8066c04f..4265b815 100644 --- a/src/federation/timeline.test.ts +++ b/src/federation/timeline.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest"; import { shouldExcludePostFromTimeline, + shouldIncludePostInList, shouldIncludePostInTimeline, } from "./timeline"; const OWNER_ID = "00000000-0000-0000-0000-000000000001"; const BLOCKED_ID = "00000000-0000-0000-0000-000000000002"; const AUTHOR_ID = "00000000-0000-0000-0000-000000000003"; +const REPLY_AUTHOR_ID = "00000000-0000-0000-0000-000000000004"; function makePost( override: Record = {}, @@ -38,6 +40,19 @@ function makeOwner( } as unknown as Parameters[1]; } +function makeFollow(followingId: string, approved: Date | null = new Date()) { + return { + iri: `https://hollo.test/@owner#follows/${followingId}`, + followerId: OWNER_ID, + followingId, + shares: true, + notify: false, + languages: null, + approved, + created: new Date(), + }; +} + describe("timeline block filtering", () => { it("excludes posts authored by blocked accounts", () => { expect.assertions(1); @@ -126,4 +141,54 @@ describe("timeline block filtering", () => { expect(shouldIncludePostInTimeline(post, owner)).toBe(false); }); + + it("excludes private posts from pending follows", () => { + expect.assertions(1); + + const post = makePost({ + accountId: AUTHOR_ID, + visibility: "private", + }); + const owner = makeOwner({ + account: { + id: OWNER_ID, + following: [makeFollow(AUTHOR_ID, null)], + blocks: [], + blockedBy: [], + mutes: [], + }, + }); + + expect(shouldIncludePostInTimeline(post, owner)).toBe(false); + }); + + it("excludes replies to pending follows from list timelines with followed replies", () => { + expect.assertions(1); + + const post = makePost({ + accountId: AUTHOR_ID, + visibility: "public", + replyTarget: makePost({ + accountId: REPLY_AUTHOR_ID, + visibility: "public", + }) as Parameters[0]["replyTarget"], + }); + const owner = makeOwner({ + account: { + id: OWNER_ID, + following: [makeFollow(REPLY_AUTHOR_ID, null)], + blocks: [], + blockedBy: [], + mutes: [], + }, + }); + + expect( + shouldIncludePostInList(post, { + accountOwner: owner, + members: [{ accountId: AUTHOR_ID }], + repliesPolicy: "followed", + } as unknown as Parameters[1]), + ).toBe(false); + }); }); diff --git a/src/federation/timeline.ts b/src/federation/timeline.ts index 5c00d607..ecce9af8 100644 --- a/src/federation/timeline.ts +++ b/src/federation/timeline.ts @@ -33,6 +33,10 @@ export const TIMELINE_INBOX_LIMIT = 1000; const logger = getLogger(["hollo", "federation", "timeline"]); +function isApprovedFollow(follow: Follow): boolean { + return follow.approved != null; +} + export function isPostVisibleToAccount( post: Post & { mentions: Mention[] }, account: Account & { following: Follow[]; blockedBy: Block[] }, @@ -49,7 +53,9 @@ export function isPostVisibleToAccount( } if (post.visibility === "private") { for (const follow of account.following) { - if (follow.followingId === post.accountId) return true; + if (isApprovedFollow(follow) && follow.followingId === post.accountId) { + return true; + } } } return false; @@ -128,13 +134,13 @@ export function shouldIncludePostInTimeline( if (mention.accountId === owner.id) return true; } for (const follow of owner.account.following) { - if (follow.followingId === post.accountId) { + if (isApprovedFollow(follow) && follow.followingId === post.accountId) { const replyTarget = post.replyTarget; return ( replyTarget == null || replyTarget.accountId === owner.id || (owner.account.following.some( - (f) => f.followingId === replyTarget.accountId, + (f) => isApprovedFollow(f) && f.followingId === replyTarget.accountId, ) && !owner.account.blocks.some( (b) => b.blockedAccountId === replyTarget.accountId, @@ -183,7 +189,7 @@ export function shouldIncludePostInList( const originalAuthorId = post.replyTarget.accountId; if (list.repliesPolicy === "followed") { return list.accountOwner.account.following.some( - (f) => f.followingId === originalAuthorId, + (f) => isApprovedFollow(f) && f.followingId === originalAuthorId, ); } if (list.repliesPolicy === "list") { diff --git a/src/schema.ts b/src/schema.ts index 9bfdd9d6..8afadc26 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -252,6 +252,9 @@ export const follows = pgTable( index() .on(table.followingId, table.approved) .where(isNotNull(table.approved)), + index("follows_follower_id_following_id_approved_index") + .on(table.followerId, table.followingId) + .where(isNotNull(table.approved)), index().on(table.followingId, table.created), ], );