diff --git a/drizzle/20260715224857_yummy_jamie_braddock/migration.sql b/drizzle/20260715224857_yummy_jamie_braddock/migration.sql new file mode 100644 index 0000000..2767819 --- /dev/null +++ b/drizzle/20260715224857_yummy_jamie_braddock/migration.sql @@ -0,0 +1,37 @@ +CREATE TABLE `mention_deliveries` ( + `id` text PRIMARY KEY, + `interaction_id` text NOT NULL UNIQUE, + `post_id` text NOT NULL, + `target_handle` text NOT NULL, + `target_domain` text NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `attempts` integer DEFAULT 0 NOT NULL, + `next_attempt_at` integer DEFAULT (unixepoch()) NOT NULL, + `last_attempt_at` integer, + `delivered_at` integer, + `last_error` text, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL, + CONSTRAINT `fk_mention_deliveries_post_id_posts_id_fk` FOREIGN KEY (`post_id`) REFERENCES `posts`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +ALTER TABLE `notifications` ADD `remote_post_id` text;--> statement-breakpoint +ALTER TABLE `notifications` ADD `remote_post_domain` text;--> statement-breakpoint +ALTER TABLE `notifications` ADD `interaction_id` text;--> statement-breakpoint +DELETE FROM `bot_mentions` +WHERE `id` IN ( + SELECT `id` FROM ( + SELECT + `id`, + ROW_NUMBER() OVER ( + PARTITION BY `bot_id`, `post_id` + ORDER BY `is_processed` DESC, `created_at` ASC, `id` ASC + ) AS `duplicate_rank` + FROM `bot_mentions` + ) + WHERE `duplicate_rank` > 1 +);--> statement-breakpoint +CREATE UNIQUE INDEX `bot_mentions_bot_post_unique_idx` ON `bot_mentions` (`bot_id`,`post_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `mention_deliveries_target_unique_idx` ON `mention_deliveries` (`post_id`,`target_handle`,`target_domain`);--> statement-breakpoint +CREATE INDEX `mention_deliveries_due_idx` ON `mention_deliveries` (`status`,`next_attempt_at`);--> statement-breakpoint +CREATE UNIQUE INDEX `notifications_interaction_unique_idx` ON `notifications` (`interaction_id`); diff --git a/drizzle/20260715224857_yummy_jamie_braddock/snapshot.json b/drizzle/20260715224857_yummy_jamie_braddock/snapshot.json new file mode 100644 index 0000000..51e5fff --- /dev/null +++ b/drizzle/20260715224857_yummy_jamie_braddock/snapshot.json @@ -0,0 +1,7471 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "8c7925d5-6d34-4533-8b91-1ed8b31d6cf7", + "prevIds": [ + "eacbe50b-bb47-41dc-b591-b3098e9859e6" + ], + "ddl": [ + { + "name": "blocks", + "entityType": "tables" + }, + { + "name": "bot_activity_logs", + "entityType": "tables" + }, + { + "name": "bot_content_items", + "entityType": "tables" + }, + { + "name": "bot_content_sources", + "entityType": "tables" + }, + { + "name": "bot_mentions", + "entityType": "tables" + }, + { + "name": "bot_rate_limits", + "entityType": "tables" + }, + { + "name": "bots", + "entityType": "tables" + }, + { + "name": "chat_conversations", + "entityType": "tables" + }, + { + "name": "chat_messages", + "entityType": "tables" + }, + { + "name": "chat_typing_indicators", + "entityType": "tables" + }, + { + "name": "e2ee_key_bundles", + "entityType": "tables" + }, + { + "name": "e2ee_key_vaults", + "entityType": "tables" + }, + { + "name": "e2ee_message_receipts", + "entityType": "tables" + }, + { + "name": "e2ee_remote_key_bundles", + "entityType": "tables" + }, + { + "name": "follows", + "entityType": "tables" + }, + { + "name": "handle_registry", + "entityType": "tables" + }, + { + "name": "likes", + "entityType": "tables" + }, + { + "name": "media", + "entityType": "tables" + }, + { + "name": "mention_deliveries", + "entityType": "tables" + }, + { + "name": "muted_nodes", + "entityType": "tables" + }, + { + "name": "mutes", + "entityType": "tables" + }, + { + "name": "nodes", + "entityType": "tables" + }, + { + "name": "notifications", + "entityType": "tables" + }, + { + "name": "posts", + "entityType": "tables" + }, + { + "name": "remote_followers", + "entityType": "tables" + }, + { + "name": "remote_follows", + "entityType": "tables" + }, + { + "name": "remote_identity_cache", + "entityType": "tables" + }, + { + "name": "remote_likes", + "entityType": "tables" + }, + { + "name": "remote_posts", + "entityType": "tables" + }, + { + "name": "remote_reposts", + "entityType": "tables" + }, + { + "name": "reports", + "entityType": "tables" + }, + { + "name": "sessions", + "entityType": "tables" + }, + { + "name": "signed_action_dedupe", + "entityType": "tables" + }, + { + "name": "stuffbox_connections", + "entityType": "tables" + }, + { + "name": "swarm_nodes", + "entityType": "tables" + }, + { + "name": "swarm_seeds", + "entityType": "tables" + }, + { + "name": "swarm_sync_log", + "entityType": "tables" + }, + { + "name": "user_swarm_likes", + "entityType": "tables" + }, + { + "name": "user_swarm_reposts", + "entityType": "tables" + }, + { + "name": "users", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "blocks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "blocks" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "blocked_user_id", + "entityType": "columns", + "table": "blocks" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "blocks" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bot_id", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "details", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "success", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "error_message", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "bot_activity_logs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_id", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "external_id", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "published_at", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "fetched_at", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_processed", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "processed_at", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "interest_score", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "interest_reason", + "entityType": "columns", + "table": "bot_content_items" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bot_id", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "subreddit", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "api_key_encrypted", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source_config", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "keywords", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "is_active", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_fetch_at", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "consecutive_errors", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "bot_content_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bot_id", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_id", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_processed", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "processed_at", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "response_post_id", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_remote", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "remote_actor_url", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "bot_mentions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bot_id", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "window_start", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "window_type", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "post_count", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "reply_count", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "bot_rate_limits" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "personality_config", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "llm_provider", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "llm_model", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "llm_endpoint", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "llm_api_key_encrypted", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "schedule_config", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "autonomous_mode", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "is_active", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_suspended", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "suspension_reason", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "suspended_at", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_post_at", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "bots" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "bots" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'direct'", + "generated": null, + "name": "type", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "participant1_id", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "participant2_handle", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_message_at", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_message_preview", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'legacy'", + "generated": null, + "name": "encryption_mode", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "e2ee_activated_at", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "chat_conversations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "conversation_id", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_handle", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_display_name", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_avatar_url", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_node_domain", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_did", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "protocol_version", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "client_message_id", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "encrypted_envelope", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "e2ee_signature", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "e2ee_action_nonce", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "e2ee_action_ts", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "swarm_message_id", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivered_at", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "read_at", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "chat_messages" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "chat_typing_indicators" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "conversation_id", + "entityType": "columns", + "table": "chat_typing_indicators" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_handle", + "entityType": "columns", + "table": "chat_typing_indicators" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "chat_typing_indicators" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "chat_typing_indicators" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "did", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_version", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "proof_action", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "e2ee_key_bundles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_version", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_did", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ciphertext", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "nonce", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "salt", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kdf_algorithm", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kdf_ops_limit", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kdf_mem_limit", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "pin_verifier_mac", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "server_share_encrypted", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "failed_attempts", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "locked_until", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "e2ee_key_vaults" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "e2ee_message_receipts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_user_id", + "entityType": "columns", + "table": "e2ee_message_receipts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sender_did", + "entityType": "columns", + "table": "e2ee_message_receipts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "e2ee_message_receipts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "1", + "generated": null, + "name": "protocol_version", + "entityType": "columns", + "table": "e2ee_message_receipts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "received_at", + "entityType": "columns", + "table": "e2ee_message_receipts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "did", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "handle", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key_version", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "proof_action", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "signing_public_key", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "first_seen_at", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "e2ee_remote_key_bundles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "follower_id", + "entityType": "columns", + "table": "follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "following_id", + "entityType": "columns", + "table": "follows" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ap_id", + "entityType": "columns", + "table": "follows" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "pending", + "entityType": "columns", + "table": "follows" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "follows" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "handle", + "entityType": "columns", + "table": "handle_registry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "did", + "entityType": "columns", + "table": "handle_registry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "node_domain", + "entityType": "columns", + "table": "handle_registry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "registered_at", + "entityType": "columns", + "table": "handle_registry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "handle_registry" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ap_id", + "entityType": "columns", + "table": "likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_provider", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_asset_id", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "alt_text", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mime_type", + "entityType": "columns", + "table": "media" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "width", + "entityType": "columns", + "table": "media" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "height", + "entityType": "columns", + "table": "media" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "media" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "interaction_id", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_handle", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_domain", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'pending'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "attempts", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "next_attempt_at", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_attempt_at", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivered_at", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "mention_deliveries" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "muted_nodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "muted_nodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "node_domain", + "entityType": "columns", + "table": "muted_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "muted_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "mutes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "mutes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "muted_user_id", + "entityType": "columns", + "table": "mutes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "mutes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "domain", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "description", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "long_description", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rules", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banner_url", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logo_url", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "favicon_url", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logo_data", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "favicon_data", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'#FFFFFF'", + "generated": null, + "name": "accent_color", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "private_key_encrypted", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_nsfw", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "turnstile_site_key", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "turnstile_secret_key", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_id", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_handle", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_display_name", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_avatar_url", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_node_domain", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_handle", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_display_name", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_avatar_url", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_node_domain", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_is_bot", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "remote_post_id", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "remote_post_domain", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_content", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "interaction_id", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "read_at", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "notifications" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bot_id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reply_to_id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repost_of_id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "swarm_reply_to_id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "swarm_reply_to_content", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "swarm_reply_to_author", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "likes_count", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "reposts_count", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "replies_count", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_nsfw", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_removed", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "removed_at", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "removed_by", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "removed_reason", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ap_id", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ap_url", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_url", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_title", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_description", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_image", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_type", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_video_url", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_media_json", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_url", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "inbox_url", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "shared_inbox_url", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "handle", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "activity_id", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "remote_followers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "follower_id", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_handle", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_actor_url", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "inbox_url", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "activity_id", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "display_name", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bio", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "avatar_url", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "remote_follows" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "did", + "entityType": "columns", + "table": "remote_identity_cache" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "remote_identity_cache" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "fetched_at", + "entityType": "columns", + "table": "remote_identity_cache" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "remote_identity_cache" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "remote_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "remote_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_handle", + "entityType": "columns", + "table": "remote_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_node_domain", + "entityType": "columns", + "table": "remote_likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "remote_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ap_id", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_handle", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_actor_url", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_display_name", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_avatar_url", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "published_at", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_url", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_title", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_description", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_image", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_type", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_video_url", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_media_json", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "media_json", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "fetched_at", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "remote_posts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "remote_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_id", + "entityType": "columns", + "table": "remote_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_handle", + "entityType": "columns", + "table": "remote_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "actor_node_domain", + "entityType": "columns", + "table": "remote_reposts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "remote_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reporter_id", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_type", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "target_id", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "reason", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'open'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "reports" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resolved_at", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resolved_by", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resolution_note", + "entityType": "columns", + "table": "reports" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "reports" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action_id", + "entityType": "columns", + "table": "signed_action_dedupe" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "did", + "entityType": "columns", + "table": "signed_action_dedupe" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "nonce", + "entityType": "columns", + "table": "signed_action_dedupe" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ts", + "entityType": "columns", + "table": "signed_action_dedupe" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "signed_action_dedupe" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "base_url", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token_encrypted", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token_expires_at", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token_encrypted", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scopes", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "connected_at", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "stuffbox_connections" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "domain", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "description", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logo_url", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "software_version", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_count", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_count", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "media_count", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_nsfw", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "discovered_via", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "discovered_at", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "last_seen_at", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_sync_at", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "consecutive_failures", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "is_active", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "50", + "generated": null, + "name": "trust_score", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_blocked", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "block_reason", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "blocked_at", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "capabilities", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "swarm_nodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "domain", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "100", + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "is_enabled", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_contact_at", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "consecutive_failures", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "swarm_seeds" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "remote_domain", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "direction", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "nodes_received", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "nodes_sent", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "handles_received", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "handles_sent", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "success", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "error_message", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "duration_ms", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "swarm_sync_log" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "node_domain", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "original_post_id", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_handle", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_display_name", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_avatar_url", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_created_at", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "likes_count", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "reposts_count", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "replies_count", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_url", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_title", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_description", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_image", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_type", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_video_url", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_media_json", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "media_json", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "liked_at", + "entityType": "columns", + "table": "user_swarm_likes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "node_domain", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "original_post_id", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_handle", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_display_name", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "author_avatar_url", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "post_created_at", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "likes_count", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "reposts_count", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "replies_count", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_url", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_title", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_description", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_image", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_type", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_video_url", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link_preview_media_json", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "media_json", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "reposted_at", + "entityType": "columns", + "table": "user_swarm_reposts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "did", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "handle", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password_hash", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "display_name", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bio", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "avatar_url", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "header_url", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "private_key_encrypted", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "public_key", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "node_id", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_bot", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "bot_owner_id", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_nsfw", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "nsfw_enabled", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "age_verified_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_suspended", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "suspension_reason", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "suspended_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "is_silenced", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "silence_reason", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "silenced_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "moved_to", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "moved_from", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "migrated_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_provider", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_endpoint", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_public_base_url", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_region", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_bucket", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_access_key_encrypted", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "storage_secret_key_encrypted", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "followers_count", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "following_count", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "posts_count", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "website", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'everyone'", + "generated": null, + "name": "dm_privacy", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "users" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_blocks_user_id_users_id_fk", + "entityType": "fks", + "table": "blocks" + }, + { + "columns": [ + "blocked_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_blocks_blocked_user_id_users_id_fk", + "entityType": "fks", + "table": "blocks" + }, + { + "columns": [ + "bot_id" + ], + "tableTo": "bots", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bot_activity_logs_bot_id_bots_id_fk", + "entityType": "fks", + "table": "bot_activity_logs" + }, + { + "columns": [ + "source_id" + ], + "tableTo": "bot_content_sources", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bot_content_items_source_id_bot_content_sources_id_fk", + "entityType": "fks", + "table": "bot_content_items" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_bot_content_items_post_id_posts_id_fk", + "entityType": "fks", + "table": "bot_content_items" + }, + { + "columns": [ + "bot_id" + ], + "tableTo": "bots", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bot_content_sources_bot_id_bots_id_fk", + "entityType": "fks", + "table": "bot_content_sources" + }, + { + "columns": [ + "bot_id" + ], + "tableTo": "bots", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bot_mentions_bot_id_bots_id_fk", + "entityType": "fks", + "table": "bot_mentions" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bot_mentions_post_id_posts_id_fk", + "entityType": "fks", + "table": "bot_mentions" + }, + { + "columns": [ + "author_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_bot_mentions_author_id_users_id_fk", + "entityType": "fks", + "table": "bot_mentions" + }, + { + "columns": [ + "response_post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_bot_mentions_response_post_id_posts_id_fk", + "entityType": "fks", + "table": "bot_mentions" + }, + { + "columns": [ + "bot_id" + ], + "tableTo": "bots", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bot_rate_limits_bot_id_bots_id_fk", + "entityType": "fks", + "table": "bot_rate_limits" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bots_user_id_users_id_fk", + "entityType": "fks", + "table": "bots" + }, + { + "columns": [ + "owner_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_bots_owner_id_users_id_fk", + "entityType": "fks", + "table": "bots" + }, + { + "columns": [ + "participant1_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_chat_conversations_participant1_id_users_id_fk", + "entityType": "fks", + "table": "chat_conversations" + }, + { + "columns": [ + "conversation_id" + ], + "tableTo": "chat_conversations", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_chat_messages_conversation_id_chat_conversations_id_fk", + "entityType": "fks", + "table": "chat_messages" + }, + { + "columns": [ + "conversation_id" + ], + "tableTo": "chat_conversations", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_chat_typing_indicators_conversation_id_chat_conversations_id_fk", + "entityType": "fks", + "table": "chat_typing_indicators" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_e2ee_key_bundles_user_id_users_id_fk", + "entityType": "fks", + "table": "e2ee_key_bundles" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_e2ee_key_vaults_user_id_users_id_fk", + "entityType": "fks", + "table": "e2ee_key_vaults" + }, + { + "columns": [ + "owner_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_e2ee_message_receipts_owner_user_id_users_id_fk", + "entityType": "fks", + "table": "e2ee_message_receipts" + }, + { + "columns": [ + "follower_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_follows_follower_id_users_id_fk", + "entityType": "fks", + "table": "follows" + }, + { + "columns": [ + "following_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_follows_following_id_users_id_fk", + "entityType": "fks", + "table": "follows" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_likes_user_id_users_id_fk", + "entityType": "fks", + "table": "likes" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_likes_post_id_posts_id_fk", + "entityType": "fks", + "table": "likes" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_media_user_id_users_id_fk", + "entityType": "fks", + "table": "media" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_media_post_id_posts_id_fk", + "entityType": "fks", + "table": "media" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_mention_deliveries_post_id_posts_id_fk", + "entityType": "fks", + "table": "mention_deliveries" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_muted_nodes_user_id_users_id_fk", + "entityType": "fks", + "table": "muted_nodes" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_mutes_user_id_users_id_fk", + "entityType": "fks", + "table": "mutes" + }, + { + "columns": [ + "muted_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_mutes_muted_user_id_users_id_fk", + "entityType": "fks", + "table": "mutes" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_notifications_user_id_users_id_fk", + "entityType": "fks", + "table": "notifications" + }, + { + "columns": [ + "actor_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_notifications_actor_id_users_id_fk", + "entityType": "fks", + "table": "notifications" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_notifications_post_id_posts_id_fk", + "entityType": "fks", + "table": "notifications" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_posts_user_id_users_id_fk", + "entityType": "fks", + "table": "posts" + }, + { + "columns": [ + "bot_id" + ], + "tableTo": "bots", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_posts_bot_id_bots_id_fk", + "entityType": "fks", + "table": "posts" + }, + { + "columns": [ + "removed_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_posts_removed_by_users_id_fk", + "entityType": "fks", + "table": "posts" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_remote_followers_user_id_users_id_fk", + "entityType": "fks", + "table": "remote_followers" + }, + { + "columns": [ + "follower_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_remote_follows_follower_id_users_id_fk", + "entityType": "fks", + "table": "remote_follows" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_remote_likes_post_id_posts_id_fk", + "entityType": "fks", + "table": "remote_likes" + }, + { + "columns": [ + "post_id" + ], + "tableTo": "posts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_remote_reposts_post_id_posts_id_fk", + "entityType": "fks", + "table": "remote_reposts" + }, + { + "columns": [ + "reporter_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_reports_reporter_id_users_id_fk", + "entityType": "fks", + "table": "reports" + }, + { + "columns": [ + "resolved_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_reports_resolved_by_users_id_fk", + "entityType": "fks", + "table": "reports" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_sessions_user_id_users_id_fk", + "entityType": "fks", + "table": "sessions" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_stuffbox_connections_user_id_users_id_fk", + "entityType": "fks", + "table": "stuffbox_connections" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_user_swarm_likes_user_id_users_id_fk", + "entityType": "fks", + "table": "user_swarm_likes" + }, + { + "columns": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_user_swarm_reposts_user_id_users_id_fk", + "entityType": "fks", + "table": "user_swarm_reposts" + }, + { + "columns": [ + "node_id" + ], + "tableTo": "nodes", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_users_node_id_nodes_id_fk", + "entityType": "fks", + "table": "users" + }, + { + "columns": [ + "bot_owner_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": true, + "name": "users_bot_owner_id_users_id_fk", + "entityType": "fks", + "table": "users" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blocks_pk", + "table": "blocks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bot_activity_logs_pk", + "table": "bot_activity_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bot_content_items_pk", + "table": "bot_content_items", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bot_content_sources_pk", + "table": "bot_content_sources", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bot_mentions_pk", + "table": "bot_mentions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bot_rate_limits_pk", + "table": "bot_rate_limits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "bots_pk", + "table": "bots", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "chat_conversations_pk", + "table": "chat_conversations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "chat_messages_pk", + "table": "chat_messages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "chat_typing_indicators_pk", + "table": "chat_typing_indicators", + "entityType": "pks" + }, + { + "columns": [ + "user_id" + ], + "nameExplicit": false, + "name": "e2ee_key_bundles_pk", + "table": "e2ee_key_bundles", + "entityType": "pks" + }, + { + "columns": [ + "user_id" + ], + "nameExplicit": false, + "name": "e2ee_key_vaults_pk", + "table": "e2ee_key_vaults", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "e2ee_message_receipts_pk", + "table": "e2ee_message_receipts", + "entityType": "pks" + }, + { + "columns": [ + "did" + ], + "nameExplicit": false, + "name": "e2ee_remote_key_bundles_pk", + "table": "e2ee_remote_key_bundles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "follows_pk", + "table": "follows", + "entityType": "pks" + }, + { + "columns": [ + "handle" + ], + "nameExplicit": false, + "name": "handle_registry_pk", + "table": "handle_registry", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "likes_pk", + "table": "likes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "media_pk", + "table": "media", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "mention_deliveries_pk", + "table": "mention_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "muted_nodes_pk", + "table": "muted_nodes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "mutes_pk", + "table": "mutes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "nodes_pk", + "table": "nodes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "notifications_pk", + "table": "notifications", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "posts_pk", + "table": "posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "remote_followers_pk", + "table": "remote_followers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "remote_follows_pk", + "table": "remote_follows", + "entityType": "pks" + }, + { + "columns": [ + "did" + ], + "nameExplicit": false, + "name": "remote_identity_cache_pk", + "table": "remote_identity_cache", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "remote_likes_pk", + "table": "remote_likes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "remote_posts_pk", + "table": "remote_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "remote_reposts_pk", + "table": "remote_reposts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "reports_pk", + "table": "reports", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sessions_pk", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": [ + "action_id" + ], + "nameExplicit": false, + "name": "signed_action_dedupe_pk", + "table": "signed_action_dedupe", + "entityType": "pks" + }, + { + "columns": [ + "user_id" + ], + "nameExplicit": false, + "name": "stuffbox_connections_pk", + "table": "stuffbox_connections", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "swarm_nodes_pk", + "table": "swarm_nodes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "swarm_seeds_pk", + "table": "swarm_seeds", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "swarm_sync_log_pk", + "table": "swarm_sync_log", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_swarm_likes_pk", + "table": "user_swarm_likes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_swarm_reposts_pk", + "table": "user_swarm_reposts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "users_pk", + "table": "users", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "blocks_user_idx", + "entityType": "indexes", + "table": "blocks" + }, + { + "columns": [ + { + "value": "blocked_user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "blocks_blocked_user_idx", + "entityType": "indexes", + "table": "blocks" + }, + { + "columns": [ + { + "value": "bot_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_activity_logs_bot_idx", + "entityType": "indexes", + "table": "bot_activity_logs" + }, + { + "columns": [ + { + "value": "action", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_activity_logs_action_idx", + "entityType": "indexes", + "table": "bot_activity_logs" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_activity_logs_created_idx", + "entityType": "indexes", + "table": "bot_activity_logs" + }, + { + "columns": [ + { + "value": "source_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_content_items_source_idx", + "entityType": "indexes", + "table": "bot_content_items" + }, + { + "columns": [ + { + "value": "is_processed", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_content_items_processed_idx", + "entityType": "indexes", + "table": "bot_content_items" + }, + { + "columns": [ + { + "value": "external_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_content_items_external_idx", + "entityType": "indexes", + "table": "bot_content_items" + }, + { + "columns": [ + { + "value": "bot_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_content_sources_bot_idx", + "entityType": "indexes", + "table": "bot_content_sources" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_content_sources_type_idx", + "entityType": "indexes", + "table": "bot_content_sources" + }, + { + "columns": [ + { + "value": "bot_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_mentions_bot_idx", + "entityType": "indexes", + "table": "bot_mentions" + }, + { + "columns": [ + { + "value": "is_processed", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_mentions_processed_idx", + "entityType": "indexes", + "table": "bot_mentions" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_mentions_created_idx", + "entityType": "indexes", + "table": "bot_mentions" + }, + { + "columns": [ + { + "value": "bot_id", + "isExpression": false + }, + { + "value": "post_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "bot_mentions_bot_post_unique_idx", + "entityType": "indexes", + "table": "bot_mentions" + }, + { + "columns": [ + { + "value": "bot_id", + "isExpression": false + }, + { + "value": "window_start", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bot_rate_limits_bot_window_idx", + "entityType": "indexes", + "table": "bot_rate_limits" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bots_user_id_idx", + "entityType": "indexes", + "table": "bots" + }, + { + "columns": [ + { + "value": "owner_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bots_owner_id_idx", + "entityType": "indexes", + "table": "bots" + }, + { + "columns": [ + { + "value": "is_active", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "bots_active_idx", + "entityType": "indexes", + "table": "bots" + }, + { + "columns": [ + { + "value": "participant1_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_conversations_participant1_idx", + "entityType": "indexes", + "table": "chat_conversations" + }, + { + "columns": [ + { + "value": "last_message_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_conversations_last_message_idx", + "entityType": "indexes", + "table": "chat_conversations" + }, + { + "columns": [ + { + "value": "participant1_id", + "isExpression": false + }, + { + "value": "participant2_handle", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "chat_conversations_unique", + "entityType": "indexes", + "table": "chat_conversations" + }, + { + "columns": [ + { + "value": "conversation_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_messages_conversation_idx", + "entityType": "indexes", + "table": "chat_messages" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_messages_created_idx", + "entityType": "indexes", + "table": "chat_messages" + }, + { + "columns": [ + { + "value": "swarm_message_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_messages_swarm_id_idx", + "entityType": "indexes", + "table": "chat_messages" + }, + { + "columns": [ + { + "value": "conversation_id", + "isExpression": false + }, + { + "value": "client_message_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "chat_messages_conversation_client_id_unique", + "entityType": "indexes", + "table": "chat_messages" + }, + { + "columns": [ + { + "value": "conversation_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_typing_conversation_idx", + "entityType": "indexes", + "table": "chat_typing_indicators" + }, + { + "columns": [ + { + "value": "expires_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "chat_typing_expires_idx", + "entityType": "indexes", + "table": "chat_typing_indicators" + }, + { + "columns": [ + { + "value": "conversation_id", + "isExpression": false + }, + { + "value": "user_handle", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "chat_typing_unique", + "entityType": "indexes", + "table": "chat_typing_indicators" + }, + { + "columns": [ + { + "value": "did", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "e2ee_key_bundles_did_unique", + "entityType": "indexes", + "table": "e2ee_key_bundles" + }, + { + "columns": [ + { + "value": "key_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "e2ee_key_bundles_key_id_unique", + "entityType": "indexes", + "table": "e2ee_key_bundles" + }, + { + "columns": [ + { + "value": "key_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "e2ee_key_vaults_key_id_unique", + "entityType": "indexes", + "table": "e2ee_key_vaults" + }, + { + "columns": [ + { + "value": "owner_user_id", + "isExpression": false + }, + { + "value": "sender_did", + "isExpression": false + }, + { + "value": "message_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "e2ee_message_receipts_owner_sender_message_unique", + "entityType": "indexes", + "table": "e2ee_message_receipts" + }, + { + "columns": [ + { + "value": "received_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "e2ee_message_receipts_received_idx", + "entityType": "indexes", + "table": "e2ee_message_receipts" + }, + { + "columns": [ + { + "value": "key_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "e2ee_remote_key_bundles_key_id_idx", + "entityType": "indexes", + "table": "e2ee_remote_key_bundles" + }, + { + "columns": [ + { + "value": "handle", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "e2ee_remote_key_bundles_handle_idx", + "entityType": "indexes", + "table": "e2ee_remote_key_bundles" + }, + { + "columns": [ + { + "value": "follower_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "follows_follower_idx", + "entityType": "indexes", + "table": "follows" + }, + { + "columns": [ + { + "value": "following_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "follows_following_idx", + "entityType": "indexes", + "table": "follows" + }, + { + "columns": [ + { + "value": "updated_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "handle_registry_updated_idx", + "entityType": "indexes", + "table": "handle_registry" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + }, + { + "value": "post_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "likes_user_post_idx", + "entityType": "indexes", + "table": "likes" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "media_user_idx", + "entityType": "indexes", + "table": "media" + }, + { + "columns": [ + { + "value": "post_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "media_post_idx", + "entityType": "indexes", + "table": "media" + }, + { + "columns": [ + { + "value": "post_id", + "isExpression": false + }, + { + "value": "target_handle", + "isExpression": false + }, + { + "value": "target_domain", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "mention_deliveries_target_unique_idx", + "entityType": "indexes", + "table": "mention_deliveries" + }, + { + "columns": [ + { + "value": "status", + "isExpression": false + }, + { + "value": "next_attempt_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "mention_deliveries_due_idx", + "entityType": "indexes", + "table": "mention_deliveries" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "muted_nodes_user_idx", + "entityType": "indexes", + "table": "muted_nodes" + }, + { + "columns": [ + { + "value": "node_domain", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "muted_nodes_domain_idx", + "entityType": "indexes", + "table": "muted_nodes" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "mutes_user_idx", + "entityType": "indexes", + "table": "mutes" + }, + { + "columns": [ + { + "value": "muted_user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "mutes_muted_user_idx", + "entityType": "indexes", + "table": "mutes" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "notifications_user_idx", + "entityType": "indexes", + "table": "notifications" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "notifications_created_idx", + "entityType": "indexes", + "table": "notifications" + }, + { + "columns": [ + { + "value": "interaction_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "notifications_interaction_unique_idx", + "entityType": "indexes", + "table": "notifications" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "posts_user_id_idx", + "entityType": "indexes", + "table": "posts" + }, + { + "columns": [ + { + "value": "bot_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "posts_bot_id_idx", + "entityType": "indexes", + "table": "posts" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "posts_created_at_idx", + "entityType": "indexes", + "table": "posts" + }, + { + "columns": [ + { + "value": "reply_to_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "posts_reply_to_idx", + "entityType": "indexes", + "table": "posts" + }, + { + "columns": [ + { + "value": "is_removed", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "posts_removed_idx", + "entityType": "indexes", + "table": "posts" + }, + { + "columns": [ + { + "value": "is_nsfw", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "posts_nsfw_idx", + "entityType": "indexes", + "table": "posts" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_followers_user_idx", + "entityType": "indexes", + "table": "remote_followers" + }, + { + "columns": [ + { + "value": "actor_url", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_followers_actor_idx", + "entityType": "indexes", + "table": "remote_followers" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + }, + { + "value": "actor_url", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "remote_followers_user_actor_unique", + "entityType": "indexes", + "table": "remote_followers" + }, + { + "columns": [ + { + "value": "follower_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_follows_follower_idx", + "entityType": "indexes", + "table": "remote_follows" + }, + { + "columns": [ + { + "value": "target_handle", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_follows_target_idx", + "entityType": "indexes", + "table": "remote_follows" + }, + { + "columns": [ + { + "value": "post_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_likes_post_idx", + "entityType": "indexes", + "table": "remote_likes" + }, + { + "columns": [ + { + "value": "actor_handle", + "isExpression": false + }, + { + "value": "actor_node_domain", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_likes_actor_idx", + "entityType": "indexes", + "table": "remote_likes" + }, + { + "columns": [ + { + "value": "post_id", + "isExpression": false + }, + { + "value": "actor_handle", + "isExpression": false + }, + { + "value": "actor_node_domain", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "remote_likes_unique", + "entityType": "indexes", + "table": "remote_likes" + }, + { + "columns": [ + { + "value": "author_handle", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_posts_author_idx", + "entityType": "indexes", + "table": "remote_posts" + }, + { + "columns": [ + { + "value": "published_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_posts_published_idx", + "entityType": "indexes", + "table": "remote_posts" + }, + { + "columns": [ + { + "value": "ap_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_posts_ap_id_idx", + "entityType": "indexes", + "table": "remote_posts" + }, + { + "columns": [ + { + "value": "post_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_reposts_post_idx", + "entityType": "indexes", + "table": "remote_reposts" + }, + { + "columns": [ + { + "value": "actor_handle", + "isExpression": false + }, + { + "value": "actor_node_domain", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "remote_reposts_actor_idx", + "entityType": "indexes", + "table": "remote_reposts" + }, + { + "columns": [ + { + "value": "post_id", + "isExpression": false + }, + { + "value": "actor_handle", + "isExpression": false + }, + { + "value": "actor_node_domain", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "remote_reposts_unique", + "entityType": "indexes", + "table": "remote_reposts" + }, + { + "columns": [ + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "reports_status_idx", + "entityType": "indexes", + "table": "reports" + }, + { + "columns": [ + { + "value": "target_type", + "isExpression": false + }, + { + "value": "target_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "reports_target_idx", + "entityType": "indexes", + "table": "reports" + }, + { + "columns": [ + { + "value": "reporter_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "reports_reporter_idx", + "entityType": "indexes", + "table": "reports" + }, + { + "columns": [ + { + "value": "token", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "sessions_token_idx", + "entityType": "indexes", + "table": "sessions" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "sessions_user_idx", + "entityType": "indexes", + "table": "sessions" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "signed_action_dedupe_created_idx", + "entityType": "indexes", + "table": "signed_action_dedupe" + }, + { + "columns": [ + { + "value": "domain", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_nodes_domain_idx", + "entityType": "indexes", + "table": "swarm_nodes" + }, + { + "columns": [ + { + "value": "is_active", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_nodes_active_idx", + "entityType": "indexes", + "table": "swarm_nodes" + }, + { + "columns": [ + { + "value": "last_seen_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_nodes_last_seen_idx", + "entityType": "indexes", + "table": "swarm_nodes" + }, + { + "columns": [ + { + "value": "trust_score", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_nodes_trust_idx", + "entityType": "indexes", + "table": "swarm_nodes" + }, + { + "columns": [ + { + "value": "is_nsfw", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_nodes_nsfw_idx", + "entityType": "indexes", + "table": "swarm_nodes" + }, + { + "columns": [ + { + "value": "is_blocked", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_nodes_blocked_idx", + "entityType": "indexes", + "table": "swarm_nodes" + }, + { + "columns": [ + { + "value": "is_enabled", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_seeds_enabled_idx", + "entityType": "indexes", + "table": "swarm_seeds" + }, + { + "columns": [ + { + "value": "priority", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_seeds_priority_idx", + "entityType": "indexes", + "table": "swarm_seeds" + }, + { + "columns": [ + { + "value": "remote_domain", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_sync_log_remote_idx", + "entityType": "indexes", + "table": "swarm_sync_log" + }, + { + "columns": [ + { + "value": "created_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "swarm_sync_log_created_idx", + "entityType": "indexes", + "table": "swarm_sync_log" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + }, + { + "value": "liked_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "user_swarm_likes_user_idx", + "entityType": "indexes", + "table": "user_swarm_likes" + }, + { + "columns": [ + { + "value": "node_domain", + "isExpression": false + }, + { + "value": "original_post_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "user_swarm_likes_post_idx", + "entityType": "indexes", + "table": "user_swarm_likes" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + }, + { + "value": "node_domain", + "isExpression": false + }, + { + "value": "original_post_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "user_swarm_likes_unique", + "entityType": "indexes", + "table": "user_swarm_likes" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + }, + { + "value": "reposted_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "user_swarm_reposts_user_idx", + "entityType": "indexes", + "table": "user_swarm_reposts" + }, + { + "columns": [ + { + "value": "node_domain", + "isExpression": false + }, + { + "value": "original_post_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "user_swarm_reposts_post_idx", + "entityType": "indexes", + "table": "user_swarm_reposts" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + }, + { + "value": "node_domain", + "isExpression": false + }, + { + "value": "original_post_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "user_swarm_reposts_unique", + "entityType": "indexes", + "table": "user_swarm_reposts" + }, + { + "columns": [ + { + "value": "handle", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_handle_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "did", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_did_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "handle", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "users_handle_unique_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "did", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "users_did_unique_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "is_suspended", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_suspended_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "is_silenced", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_silenced_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "is_bot", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_is_bot_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "bot_owner_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_bot_owner_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + { + "value": "is_nsfw", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "users_nsfw_idx", + "entityType": "indexes", + "table": "users" + }, + { + "columns": [ + "ap_id" + ], + "nameExplicit": false, + "name": "follows_ap_id_unique", + "entityType": "uniques", + "table": "follows" + }, + { + "columns": [ + "ap_id" + ], + "nameExplicit": false, + "name": "likes_ap_id_unique", + "entityType": "uniques", + "table": "likes" + }, + { + "columns": [ + "interaction_id" + ], + "nameExplicit": false, + "name": "mention_deliveries_interaction_id_unique", + "entityType": "uniques", + "table": "mention_deliveries" + }, + { + "columns": [ + "domain" + ], + "nameExplicit": false, + "name": "nodes_domain_unique", + "entityType": "uniques", + "table": "nodes" + }, + { + "columns": [ + "ap_id" + ], + "nameExplicit": false, + "name": "posts_ap_id_unique", + "entityType": "uniques", + "table": "posts" + }, + { + "columns": [ + "domain" + ], + "nameExplicit": false, + "name": "swarm_seeds_domain_unique", + "entityType": "uniques", + "table": "swarm_seeds" + }, + { + "columns": [ + "email" + ], + "nameExplicit": false, + "name": "users_email_unique", + "entityType": "uniques", + "table": "users" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/src/app/api/mentions/suggestions/route.ts b/src/app/api/mentions/suggestions/route.ts new file mode 100644 index 0000000..94c32db --- /dev/null +++ b/src/app/api/mentions/suggestions/route.ts @@ -0,0 +1,231 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, like, notLike, or } from 'drizzle-orm'; +import { z } from 'zod'; + +import { db, blocks, mutedNodes, mutes, remoteFollows, users } from '@/db'; +import { requireAuth } from '@/lib/auth'; +import { discoverNode } from '@/lib/swarm/discovery'; +import { isSwarmNode } from '@/lib/swarm/interactions'; +import { getPublicSwarmDomain, normalizeNodeDomain } from '@/lib/swarm/node-domain'; +import { safeFederationRequest } from '@/lib/swarm/safe-federation-http'; +import { resolveUserHandle } from '@/lib/swarm/user-handle'; +import { isValidNodeDomain } from '@/lib/utils/federation'; + +const querySchema = z.object({ + q: z.string().max(280), + limit: z.coerce.number().int().min(1).max(12).default(8), +}); + +const remoteDirectorySchema = z.object({ + users: z.array(z.object({ + handle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/), + displayName: z.string().max(100).nullable(), + avatarUrl: z.string().url().nullable(), + isBot: z.boolean(), + })).max(12), +}); + +export interface MentionSuggestion { + handle: string; + displayName: string | null; + avatarUrl: string | null; + isBot: boolean; + isRemote: boolean; + nodeDomain: string | null; +} + +async function excludedLocalUserIds(viewerId: string): Promise> { + const [blockRows, muteRows] = await Promise.all([ + db.select({ userId: blocks.userId, blockedUserId: blocks.blockedUserId }) + .from(blocks) + .where(or(eq(blocks.userId, viewerId), eq(blocks.blockedUserId, viewerId))), + db.select({ mutedUserId: mutes.mutedUserId }) + .from(mutes) + .where(eq(mutes.userId, viewerId)), + ]); + + const ids = new Set(muteRows.map((row) => row.mutedUserId)); + for (const row of blockRows) { + ids.add(row.userId === viewerId ? row.blockedUserId : row.userId); + } + return ids; +} + +async function localSuggestions( + query: string, + limit: number, + excludedIds: ReadonlySet, +): Promise { + const pattern = `${query.toLowerCase()}%`; + const rows = await db.select({ + id: users.id, + handle: users.handle, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + isBot: users.isBot, + }) + .from(users) + .where(and( + or(like(users.handle, pattern), like(users.displayName, pattern)), + notLike(users.handle, '%@%'), + eq(users.isSuspended, false), + eq(users.isSilenced, false), + )) + .limit(Math.min(30, limit + excludedIds.size + 4)); + + return rows + .filter((row) => !row.handle.includes('@') && !excludedIds.has(row.id)) + .sort((left, right) => { + const leftExact = left.handle.toLowerCase() === query.toLowerCase() ? 0 : 1; + const rightExact = right.handle.toLowerCase() === query.toLowerCase() ? 0 : 1; + return leftExact - rightExact || left.handle.localeCompare(right.handle); + }) + .slice(0, limit) + .map((row) => ({ + handle: row.handle, + displayName: row.displayName, + avatarUrl: row.avatarUrl, + isBot: row.isBot, + isRemote: false, + nodeDomain: null, + })); +} + +async function mutedNodeDomains(viewerId: string): Promise> { + const rows = await db.select({ nodeDomain: mutedNodes.nodeDomain }) + .from(mutedNodes) + .where(eq(mutedNodes.userId, viewerId)); + return new Set(rows.map((row) => normalizeNodeDomain(row.nodeDomain))); +} + +async function fetchRemoteSuggestions( + handleQuery: string, + domain: string, + limit: number, +): Promise { + let known = await isSwarmNode(domain); + if (!known) known = (await discoverNode(domain)).success; + if (!known) return []; + + const publicDomain = getPublicSwarmDomain(domain); + const isDevelopmentLoopback = process.env.NODE_ENV === 'development' + && /^(?:localhost|127\.0\.0\.1)(?::\d{1,5})?$/.test(domain); + if (!publicDomain && !isDevelopmentLoopback) return []; + + const protocol = isDevelopmentLoopback ? 'http' : 'https'; + const url = new URL('/api/swarm/users', `${protocol}://${publicDomain || domain}`); + url.searchParams.set('q', handleQuery); + url.searchParams.set('limit', String(limit)); + const response = await safeFederationRequest(url.toString(), { + headers: { Accept: 'application/json' }, + maxResponseBytes: 64 * 1024, + timeoutMs: 4_000, + }); + if (response.status < 200 || response.status >= 300) return []; + + const parsed = remoteDirectorySchema.safeParse(response.json()); + if (!parsed.success) return []; + return parsed.data.users.map((user) => ({ + ...user, + handle: `${user.handle.toLowerCase()}@${domain}`, + isRemote: true, + nodeDomain: domain, + })); +} + +export async function GET(request: NextRequest) { + try { + const viewer = await requireAuth(); + const parsed = querySchema.safeParse({ + q: (request.nextUrl.searchParams.get('q') || '').replace(/^@/, '').trim(), + limit: request.nextUrl.searchParams.get('limit') || undefined, + }); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid query' }, { status: 400 }); + } + + const query = parsed.data.q.toLowerCase(); + const excludedIds = await excludedLocalUserIds(viewer.id); + const separator = query.indexOf('@'); + + if (separator >= 0) { + const handleQuery = query.slice(0, separator); + const requestedDomain = query.slice(separator + 1); + if (!/^[a-zA-Z0-9_]{0,30}$/.test(handleQuery) + || !isValidNodeDomain(requestedDomain)) { + return NextResponse.json({ suggestions: [] }); + } + + const resolution = resolveUserHandle(`user@${requestedDomain}`); + if (resolution.isLocal) { + return NextResponse.json({ + suggestions: await localSuggestions(handleQuery, parsed.data.limit, excludedIds), + }); + } + + const domain = normalizeNodeDomain(requestedDomain); + const mutedDomains = await mutedNodeDomains(viewer.id); + if (mutedDomains.has(domain)) { + return NextResponse.json({ suggestions: [] }); + } + + const suggestions = await fetchRemoteSuggestions(handleQuery, domain, parsed.data.limit); + const cachedRemoteUsers = suggestions.length + ? await db.select({ id: users.id, handle: users.handle }) + .from(users) + .where(or(...suggestions.map((suggestion) => eq(users.handle, suggestion.handle)))) + : []; + const excludedHandles = new Set( + cachedRemoteUsers.filter((user) => excludedIds.has(user.id)).map((user) => user.handle.toLowerCase()), + ); + return NextResponse.json({ + suggestions: suggestions.filter((suggestion) => !excludedHandles.has(suggestion.handle.toLowerCase())), + }); + } + + const local = await localSuggestions(query, parsed.data.limit, excludedIds); + if (local.length >= parsed.data.limit) { + return NextResponse.json({ suggestions: local }); + } + + const mutedDomains = await mutedNodeDomains(viewer.id); + const knownRemote = await db.select({ + handle: remoteFollows.targetHandle, + displayName: remoteFollows.displayName, + avatarUrl: remoteFollows.avatarUrl, + }) + .from(remoteFollows) + .where(and( + eq(remoteFollows.followerId, viewer.id), + or( + like(remoteFollows.targetHandle, `${query}%`), + like(remoteFollows.displayName, `${query}%`), + ), + )) + .limit(parsed.data.limit); + + const seen = new Set(local.map((item) => item.handle.toLowerCase())); + const remote = knownRemote.flatMap((row) => { + const parts = row.handle.toLowerCase().split('@'); + if (parts.length !== 2 || !parts[0] || !parts[1] || mutedDomains.has(normalizeNodeDomain(parts[1]))) return []; + if (seen.has(row.handle.toLowerCase())) return []; + seen.add(row.handle.toLowerCase()); + return [{ + handle: row.handle.toLowerCase(), + displayName: row.displayName, + avatarUrl: row.avatarUrl, + isBot: false, + isRemote: true, + nodeDomain: parts[1], + }]; + }); + + return NextResponse.json({ suggestions: [...local, ...remote].slice(0, parsed.data.limit) }); + } catch (error) { + if (error instanceof Error && ['Unauthorized', 'Authentication required'].includes(error.message)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + console.error('[Mentions] Suggestion lookup failed:', error); + return NextResponse.json({ error: 'Failed to load mention suggestions' }, { status: 500 }); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index d79b8d6..5277d89 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -91,6 +91,9 @@ export async function GET(request: Request) { const payload = rows.map((row) => { const key = row.actorNodeDomain ? `${row.actorHandle}@${row.actorNodeDomain}` : null; const freshProfile = key ? freshProfiles.get(key) : null; + const remotePostReference = row.remotePostId && row.remotePostDomain + ? `swarm:${row.remotePostDomain}:${row.remotePostId}` + : null; return { id: row.id, @@ -114,10 +117,12 @@ export async function GET(request: Request) { nodeDomain: row.targetNodeDomain, isBot: row.targetIsBot, } : null, - post: row.postId ? { - id: row.postId, + post: row.postId || remotePostReference ? { + id: row.postId || remotePostReference!, content: row.post?.content || row.postContent, - authorHandle: row.post?.author?.handle || null, + authorHandle: row.post?.author?.handle || (row.actorNodeDomain + ? `${row.actorHandle}@${row.actorNodeDomain}` + : row.actorHandle), media: row.post?.media.map((item) => ({ url: item.url, mimeType: item.mimeType, diff --git a/src/app/api/posts/route.test.ts b/src/app/api/posts/route.test.ts index 043bf7b..352a8d3 100644 --- a/src/app/api/posts/route.test.ts +++ b/src/app/api/posts/route.test.ts @@ -14,6 +14,14 @@ vi.mock('@/lib/auth/verify-signature', () => ({ requireSignedAction: vi.fn(), })); +vi.mock('@/lib/mentions/delivery', () => ({ + registerPostMentions: vi.fn().mockResolvedValue({ + localNotifications: 0, + remoteQueued: 0, + skipped: 0, + }), +})); + vi.mock('@/db', () => ({ db: { insert: vi.fn(() => ({ @@ -80,8 +88,9 @@ describe('POST /api/posts', () => { }, did: 'did:synapsis:test123', handle: 'testuser', - timestamp: new Date().toISOString(), - signature: 'test-signature', + ts: Date.now(), + nonce: 'nonce-1', + sig: 'test-signature', }; // Create a mock request @@ -118,8 +127,9 @@ describe('POST /api/posts', () => { }, did: 'did:synapsis:test123', handle: 'testuser', - timestamp: new Date().toISOString(), - signature: 'invalid-signature', + ts: Date.now(), + nonce: 'nonce-2', + sig: 'invalid-signature', }; const request = new Request('http://localhost:43821/api/posts', { @@ -148,8 +158,9 @@ describe('POST /api/posts', () => { }, did: 'did:synapsis:nonexistent', handle: 'nonexistent', - timestamp: new Date().toISOString(), - signature: 'test-signature', + ts: Date.now(), + nonce: 'nonce-3', + sig: 'test-signature', }; const request = new Request('http://localhost:43821/api/posts', { @@ -178,8 +189,9 @@ describe('POST /api/posts', () => { }, did: 'did:synapsis:test123', handle: 'wronghandle', - timestamp: new Date().toISOString(), - signature: 'test-signature', + ts: Date.now(), + nonce: 'nonce-4', + sig: 'test-signature', }; const request = new Request('http://localhost:43821/api/posts', { @@ -208,8 +220,9 @@ describe('POST /api/posts', () => { }, did: 'did:synapsis:test123', handle: 'testuser', - timestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 minutes ago - signature: 'test-signature', + ts: Date.now() - 10 * 60 * 1000, + nonce: 'nonce-5', + sig: 'test-signature', }; const request = new Request('http://localhost:43821/api/posts', { @@ -249,8 +262,9 @@ describe('POST /api/posts', () => { }, did: 'did:synapsis:test123', handle: 'testuser', - timestamp: new Date().toISOString(), - signature: 'test-signature', + ts: Date.now(), + nonce: 'nonce-6', + sig: 'test-signature', }; const request = new Request('http://localhost:43821/api/posts', { @@ -289,8 +303,9 @@ describe('POST /api/posts', () => { }, did: 'did:synapsis:test123', handle: 'testuser', - timestamp: new Date().toISOString(), - signature: 'test-signature', + ts: Date.now(), + nonce: 'nonce-7', + sig: 'test-signature', }; const request = new Request('http://localhost:43821/api/posts', { diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 293e531..8273c17 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -16,6 +16,7 @@ import { CURATED_FEED_WINDOW_HOURS, rankCuratedFeed, } from '@/lib/posts/curated-feed'; +import { registerPostMentions } from '@/lib/mentions/delivery'; const POST_MAX_LENGTH = 600; const CURATION_SEED_MULTIPLIER = 5; @@ -406,85 +407,26 @@ export async function POST(request: Request) { } } - // Handle local mentions (create notifications for users on this node) - (async () => { - try { - const { extractMentions } = await import('@/lib/swarm/interactions'); - const mentions = extractMentions(postContent); - - for (const mention of mentions) { - // Only handle local mentions (no domain) - if (mention.domain) continue; - - // Find the mentioned user - const mentionedUser = await db.query.users.findFirst({ - where: { handle: mention.handle.toLowerCase() }, - }); - - if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) { - // Create notification for the mentioned user with actor info stored directly - await db.insert(notifications).values({ - userId: mentionedUser.id, - actorId: user.id, - actorHandle: user.handle, - actorDisplayName: user.displayName, - actorAvatarUrl: user.avatarUrl, - actorNodeDomain: null, // Local user - postId: post.id, - postContent: post.content?.slice(0, 200) || null, - ...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}), - type: 'mention', - }); - - // Also notify bot owner if this is a bot being mentioned - if (mentionedUser.isBot && mentionedUser.botOwnerId) { - await db.insert(notifications).values({ - userId: mentionedUser.botOwnerId, - actorId: user.id, - actorHandle: user.handle, - actorDisplayName: user.displayName, - actorAvatarUrl: user.avatarUrl, - actorNodeDomain: null, - postId: post.id, - postContent: post.content?.slice(0, 200) || null, - ...buildNotificationTarget(mentionedUser), - type: 'mention', - }); - } - } - } - } catch (err) { - // Log error with context but don't fail the request - mention notifications are best-effort - console.error('[Posts] Error creating mention notifications:', err); - console.error('[Posts] Context:', { postId: post.id, userId: user.id, content: postContent.slice(0, 100) }); - } - })(); - - // SWARM-FIRST: Deliver mentions to swarm nodes - (async () => { - try { - const { deliverSwarmMentions } = await import('@/lib/swarm/interactions'); - - const result = await deliverSwarmMentions( - postContent, - post.id, - { - handle: user.handle, - displayName: user.displayName || user.handle, - avatarUrl: user.avatarUrl || undefined, - nodeDomain, - } - ); - - if (result.delivered > 0) { - console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`); - } - } catch (err) { - // Log error with context but don't fail the request - swarm delivery is best-effort - console.error('[Posts] Error delivering swarm mentions:', err); - console.error('[Posts] Context:', { postId: post.id, userId: user.id, nodeDomain }); - } - })(); + // Resolve local mentions and durably enqueue federated delivery before + // returning. Remote network I/O is retried from the persistent outbox. + try { + await registerPostMentions({ + postId: post.id, + content: postContent, + actor: { + id: user.id, + handle: user.handle, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + did: user.did, + publicKey: user.publicKey, + }, + nodeDomain, + }); + } catch (err) { + console.error('[Posts] Error registering mentions:', err); + console.error('[Posts] Context:', { postId: post.id, userId: user.id, content: postContent.slice(0, 100) }); + } // Federate the post to remote followers (non-blocking) (async () => { diff --git a/src/app/api/swarm/interactions/mention/route.test.ts b/src/app/api/swarm/interactions/mention/route.test.ts new file mode 100644 index 0000000..a34c905 --- /dev/null +++ b/src/app/api/swarm/interactions/mention/route.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + verifySwarmRequest: vi.fn(), + usersFindFirst: vi.fn(), + mutedNodeFindFirst: vi.fn(), + blockFindFirst: vi.fn(), + muteFindFirst: vi.fn(), + notificationValues: vi.fn(), + notificationReturning: vi.fn(), +})); + +vi.mock('@/lib/swarm/signature', () => ({ + verifySwarmRequest: mocks.verifySwarmRequest, +})); + +vi.mock('@/db', () => ({ + notifications: { id: 'id' }, + db: { + query: { + users: { findFirst: mocks.usersFindFirst }, + mutedNodes: { findFirst: mocks.mutedNodeFindFirst }, + blocks: { findFirst: mocks.blockFindFirst }, + mutes: { findFirst: mocks.muteFindFirst }, + }, + insert: vi.fn(() => ({ + values: (values: unknown) => { + mocks.notificationValues(values); + return { + onConflictDoNothing: () => ({ + returning: mocks.notificationReturning, + then: (resolve: (value: unknown) => unknown) => Promise.resolve(undefined).then(resolve), + }), + }; + }, + })), + }, +})); + +import { POST } from './route'; + +const interactionId = '550e8400-e29b-41d4-a716-446655440000'; +const postId = '6ba7b810-9dad-41d1-80b4-00c04fd430c8'; + +function request() { + return new Request('https://local.example/api/swarm/interactions/mention', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mentionedHandle: 'localuser', + mention: { + actorHandle: 'remoteuser', + actorDisplayName: 'Remote User', + actorNodeDomain: 'remote.example', + postId, + postContent: 'Hello @localuser@local.example', + interactionId, + timestamp: '2026-07-15T22:00:00.000Z', + }, + signature: 'signed', + }), + }); +} + +describe('swarm mention receiver', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.verifySwarmRequest.mockResolvedValue(true); + mocks.usersFindFirst + .mockResolvedValueOnce({ + id: 'recipient-id', + handle: 'localuser', + isSuspended: false, + isBot: false, + botOwnerId: null, + }) + .mockResolvedValueOnce(null); + mocks.mutedNodeFindFirst.mockResolvedValue(null); + mocks.blockFindFirst.mockResolvedValue(null); + mocks.muteFindFirst.mockResolvedValue(null); + mocks.notificationReturning.mockResolvedValue([{ id: 'notification-id' }]); + }); + + it('stores an idempotent, navigable remote post reference', async () => { + const response = await POST(request() as never); + + expect(response.status).toBe(200); + expect(mocks.notificationValues).toHaveBeenCalledWith(expect.objectContaining({ + userId: 'recipient-id', + interactionId: `mention:remote:remote.example:${interactionId}`, + remotePostId: postId, + remotePostDomain: 'remote.example', + actorNodeDomain: 'remote.example', + type: 'mention', + })); + expect(mocks.notificationReturning).toHaveBeenCalledOnce(); + }); + + it('acknowledges but suppresses a mention from a muted node', async () => { + mocks.mutedNodeFindFirst.mockResolvedValue({ id: 'mute-id' }); + + const response = await POST(request() as never); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ success: true }); + expect(mocks.notificationValues).not.toHaveBeenCalled(); + }); + + it('rejects an invalid node signature before resolving the recipient', async () => { + mocks.verifySwarmRequest.mockResolvedValue(false); + + const response = await POST(request() as never); + + expect(response.status).toBe(403); + expect(mocks.usersFindFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/swarm/interactions/mention/route.ts b/src/app/api/swarm/interactions/mention/route.ts index 6c06aea..d0ecb91 100644 --- a/src/app/api/swarm/interactions/mention/route.ts +++ b/src/app/api/swarm/interactions/mention/route.ts @@ -7,12 +7,12 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, users, notifications } from '@/db'; -import { eq } from 'drizzle-orm'; +import { db, notifications } from '@/db'; import { z } from 'zod'; import { verifySwarmRequest } from '@/lib/swarm/signature'; import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation'; import { buildNotificationTarget } from '@/lib/notifications'; +import { normalizeNodeDomain } from '@/lib/swarm/node-domain'; const swarmMentionSchema = z.object({ mentionedHandle: localHandleSchema, @@ -21,6 +21,8 @@ const swarmMentionSchema = z.object({ actorDisplayName: z.string().min(1).max(50), actorAvatarUrl: z.string().url().optional(), actorNodeDomain: nodeDomainSchema, + actorDid: z.string().min(1).max(500).optional(), + actorPublicKey: z.string().min(1).max(5000).optional(), postId: z.string().uuid(), postContent: z.string().max(10000), interactionId: z.string().uuid(), @@ -29,6 +31,36 @@ const swarmMentionSchema = z.object({ signature: z.string(), }); +async function acceptsRemoteMention( + userId: string, + actorDomain: string, + cachedActorId: string | null, +): Promise { + const nodeMute = await db.query.mutedNodes.findFirst({ + where: { AND: [{ userId }, { nodeDomain: actorDomain }] }, + columns: { id: true }, + }); + if (nodeMute) return false; + if (!cachedActorId) return true; + + const [block, mute] = await Promise.all([ + db.query.blocks.findFirst({ + where: { + OR: [ + { AND: [{ userId }, { blockedUserId: cachedActorId }] }, + { AND: [{ userId: cachedActorId }, { blockedUserId: userId }] }, + ], + }, + columns: { id: true }, + }), + db.query.mutes.findFirst({ + where: { AND: [{ userId }, { mutedUserId: cachedActorId }] }, + columns: { id: true }, + }), + ]); + return !block && !mute; +} + /** * POST /api/swarm/interactions/mention * @@ -65,39 +97,53 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } - // Create notification with actor info stored directly - try { - await db.insert(notifications).values({ + const actorDomain = normalizeNodeDomain(data.mention.actorNodeDomain); + const interactionKey = `mention:remote:${actorDomain}:${data.mention.interactionId}`; + const cachedActor = await db.query.users.findFirst({ + where: { handle: `${data.mention.actorHandle.toLowerCase()}@${actorDomain}` }, + columns: { id: true }, + }); + if (!(await acceptsRemoteMention(mentionedUser.id, actorDomain, cachedActor?.id || null))) { + // Do not disclose moderation state to the sending node. + return NextResponse.json({ success: true, message: 'Mention received' }); + } + + // The interaction ID is persisted under a unique index. A retried signed + // request therefore remains successful without creating duplicates. + const inserted = await db.insert(notifications).values({ userId: mentionedUser.id, actorHandle: data.mention.actorHandle, actorDisplayName: data.mention.actorDisplayName, actorAvatarUrl: data.mention.actorAvatarUrl || null, - actorNodeDomain: data.mention.actorNodeDomain, + actorNodeDomain: actorDomain, + remotePostId: data.mention.postId, + remotePostDomain: actorDomain, postContent: data.mention.postContent.slice(0, 200), + interactionId: interactionKey, ...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}), type: 'mention', - }); + }).onConflictDoNothing().returning({ id: notifications.id }); + if (inserted.length > 0) { console.log(`[Swarm] Created mention notification for @${data.mentionedHandle} from ${data.mention.actorHandle}@${data.mention.actorNodeDomain}`); - } catch (notifError) { - console.error(`[Swarm] Failed to create mention notification:`, notifError); } // Also notify bot owner if this is a bot being mentioned - if (mentionedUser.isBot && mentionedUser.botOwnerId) { - try { + if (mentionedUser.isBot + && mentionedUser.botOwnerId + && await acceptsRemoteMention(mentionedUser.botOwnerId, actorDomain, cachedActor?.id || null)) { await db.insert(notifications).values({ userId: mentionedUser.botOwnerId, actorHandle: data.mention.actorHandle, actorDisplayName: data.mention.actorDisplayName, actorAvatarUrl: data.mention.actorAvatarUrl || null, - actorNodeDomain: data.mention.actorNodeDomain, + actorNodeDomain: actorDomain, + remotePostId: data.mention.postId, + remotePostDomain: actorDomain, postContent: data.mention.postContent.slice(0, 200), + interactionId: `${interactionKey}:owner:${mentionedUser.id}`, ...buildNotificationTarget(mentionedUser), type: 'mention', - }); - } catch (err) { - console.error('[Swarm] Failed to notify bot owner:', err); - } + }).onConflictDoNothing(); } console.log(`[Swarm] Received mention from ${data.mention.actorHandle}@${data.mention.actorNodeDomain} for @${data.mentionedHandle}`); diff --git a/src/app/api/swarm/users/route.ts b/src/app/api/swarm/users/route.ts new file mode 100644 index 0000000..c64c5da --- /dev/null +++ b/src/app/api/swarm/users/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, asc, eq, like, notLike } from 'drizzle-orm'; +import { z } from 'zod'; + +import { db, users } from '@/db'; + +const querySchema = z.object({ + q: z.string().max(30).regex(/^[a-zA-Z0-9_]*$/), + limit: z.coerce.number().int().min(1).max(12).default(8), +}); + +/** Public, bounded local user directory used by federated mention typeahead. */ +export async function GET(request: NextRequest) { + const parsed = querySchema.safeParse({ + q: request.nextUrl.searchParams.get('q') || '', + limit: request.nextUrl.searchParams.get('limit') || undefined, + }); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid query' }, { status: 400 }); + } + + const query = parsed.data.q.toLowerCase(); + const matches = await db.select({ + handle: users.handle, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + isBot: users.isBot, + }) + .from(users) + .where(and( + like(users.handle, `${query}%`), + notLike(users.handle, '%@%'), + eq(users.isSuspended, false), + eq(users.isSilenced, false), + )) + .orderBy(asc(users.handle)) + .limit(parsed.data.limit); + + return NextResponse.json({ users: matches }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 4572073..37fc738 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -385,6 +385,23 @@ a.btn-primary:visited { word-wrap: break-word; } +.post-content a, +.post-content .mention-link { + position: relative; + z-index: 2; +} + +.mention-link { + color: var(--accent); + font-weight: 550; + text-decoration: none; +} + +.mention-link:hover, +.mention-link:focus-visible { + text-decoration: underline; +} + .repost-event-header { display: flex; align-items: center; @@ -759,6 +776,7 @@ a.btn-primary:visited { .compose { padding: 16px; border-bottom: 1px solid var(--border); + position: relative; } .compose-input { @@ -780,6 +798,101 @@ a.btn-primary:visited { color: var(--foreground-tertiary); } +.compose-mention-field { + position: relative; +} + +.compose-mention-suggestions { + position: absolute; + z-index: 40; + top: calc(100% + 4px); + left: 8px; + right: 8px; + max-height: 288px; + overflow-y: auto; + padding: 6px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--background); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28); +} + +.compose-mention-suggestion { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--foreground); + text-align: left; + cursor: pointer; +} + +.compose-mention-suggestion:hover, +.compose-mention-suggestion.selected { + background: var(--background-secondary); +} + +.compose-mention-avatar { + width: 34px; + height: 34px; + flex: 0 0 34px; + overflow: hidden; + border-radius: 50%; +} + +.compose-mention-avatar img, +.compose-mention-avatar .avatar-fallback { + width: 100%; + height: 100%; +} + +.compose-mention-identity { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.compose-mention-name, +.compose-mention-handle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compose-mention-name { + display: flex; + align-items: center; + gap: 7px; + font-size: 14px; + font-weight: 600; +} + +.compose-mention-handle, +.compose-mention-loading { + color: var(--foreground-tertiary); + font-size: 13px; +} + +.compose-mention-bot { + padding: 1px 5px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--foreground-secondary); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.compose-mention-loading { + padding: 12px; +} + .compose-footer { display: flex; justify-content: space-between; diff --git a/src/components/AutoTextarea.tsx b/src/components/AutoTextarea.tsx index f244f32..28113a4 100644 --- a/src/components/AutoTextarea.tsx +++ b/src/components/AutoTextarea.tsx @@ -1,10 +1,12 @@ 'use client'; -import { useEffect, useRef, TextareaHTMLAttributes } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useRef, TextareaHTMLAttributes } from 'react'; -export default function AutoTextarea(props: TextareaHTMLAttributes) { +const AutoTextarea = forwardRef>(function AutoTextarea(props, forwardedRef) { const textareaRef = useRef(null); + useImperativeHandle(forwardedRef, () => textareaRef.current as HTMLTextAreaElement, []); + const adjustHeight = () => { const textarea = textareaRef.current; if (textarea) { @@ -35,4 +37,6 @@ export default function AutoTextarea(props: TextareaHTMLAttributes ); -} +}); + +export default AutoTextarea; diff --git a/src/components/Compose.tsx b/src/components/Compose.tsx index 9e15cde..db71501 100644 --- a/src/components/Compose.tsx +++ b/src/components/Compose.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useId, useRef } from 'react'; import AutoTextarea from '@/components/AutoTextarea'; import { Post, Attachment } from '@/lib/types'; import { AlertTriangle, Music2, Paperclip } from 'lucide-react'; @@ -10,14 +10,31 @@ import { useAuth } from '@/lib/contexts/AuthContext'; import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt'; import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload'; import { getMediaKind } from '@/lib/media/upload-policy'; +import { AvatarImage } from '@/components/AvatarImage'; +import type { LinkPreviewData } from '@/lib/media/linkPreview'; +import { + getActiveMentionQuery, + parseMentions, + replaceMentionQuery, + type ActiveMentionQuery, +} from '@/lib/mentions/parser'; interface MediaAttachment extends Attachment { mimeType?: string; filename?: string; } +interface MentionSuggestion { + handle: string; + displayName: string | null; + avatarUrl: string | null; + isBot: boolean; + isRemote: boolean; + nodeDomain: string | null; +} + interface ComposeProps { - onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void | boolean | Promise; + onPost: (content: string, mediaIds: string[], linkPreview?: LinkPreviewData, replyToId?: string, isNsfw?: boolean) => void | boolean | Promise; onPosted?: () => void; replyingTo?: Post | null; onCancelReply?: () => void; @@ -28,7 +45,7 @@ interface ComposeProps { export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placeholder = "What's happening?", isReply, autoFocus = false }: ComposeProps) { const { isIdentityUnlocked } = useAuth(); - const replyToHandle = replyingTo ? useFormattedHandle(replyingTo.author.handle) : ''; + const replyToHandle = useFormattedHandle(replyingTo?.author.handle || ''); const [content, setContent] = useState(''); const [isPosting, setIsPosting] = useState(false); const [attachments, setAttachments] = useState([]); @@ -38,13 +55,18 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold const [storageNotice, setStorageNotice] = useState(null); const [pendingStorageFiles, setPendingStorageFiles] = useState([]); const [showStorageConfiguration, setShowStorageConfiguration] = useState(false); - const [linkPreview, setLinkPreview] = useState(null); - const [fetchingPreview, setFetchingPreview] = useState(false); + const [linkPreview, setLinkPreview] = useState(null); const [lastDetectedUrl, setLastDetectedUrl] = useState(null); const [isNsfw, setIsNsfw] = useState(false); const [canPostNsfw, setCanPostNsfw] = useState(false); const [isNsfwNode, setIsNsfwNode] = useState(false); const mediaInputRef = useRef(null); + const textareaRef = useRef(null); + const mentionListId = useId(); + const [activeMention, setActiveMention] = useState(null); + const [mentionSuggestions, setMentionSuggestions] = useState([]); + const [mentionLoading, setMentionLoading] = useState(false); + const [selectedMentionIndex, setSelectedMentionIndex] = useState(0); const maxLength = 600; const remaining = maxLength - content.length; const canSubmit = content.trim().length > 0 || attachments.length > 0; @@ -80,10 +102,16 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold // Detect URLs in content useEffect(() => { const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi; - const matches = content.match(urlRegex); + const mentionRanges = parseMentions(content); + const matches = Array.from(content.matchAll(urlRegex)) + .filter((match) => { + const start = match.index || 0; + const end = start + match[0].length; + return !mentionRanges.some((mention) => start < mention.end && end > mention.start); + }); - if (matches && matches[0]) { - const url = matches[0]; + if (matches[0]) { + const url = matches[0][0]; if (url !== lastDetectedUrl) { setLastDetectedUrl(url); fetchPreview(url); @@ -94,8 +122,83 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold } }, [content, lastDetectedUrl]); + useEffect(() => { + if (!activeMention) { + setMentionSuggestions([]); + setMentionLoading(false); + return; + } + + const controller = new AbortController(); + const timeout = window.setTimeout(async () => { + setMentionLoading(true); + try { + const response = await fetch( + `/api/mentions/suggestions?q=${encodeURIComponent(activeMention.query)}&limit=8`, + { signal: controller.signal, cache: 'no-store' }, + ); + if (!response.ok) { + setMentionSuggestions([]); + return; + } + const data = await response.json(); + const suggestions = Array.isArray(data.suggestions) ? data.suggestions : []; + setMentionSuggestions(suggestions); + setSelectedMentionIndex(0); + } catch (error) { + if (!(error instanceof DOMException && error.name === 'AbortError')) { + console.warn('[Compose] Mention suggestions failed:', error); + } + } finally { + if (!controller.signal.aborted) setMentionLoading(false); + } + }, 160); + + return () => { + window.clearTimeout(timeout); + controller.abort(); + }; + }, [activeMention]); + + const syncActiveMention = (value: string, caret: number | null) => { + setActiveMention(getActiveMentionQuery(value, caret ?? value.length)); + }; + + const chooseMention = (suggestion: MentionSuggestion) => { + if (!activeMention) return; + const replacement = replaceMentionQuery(content, activeMention, suggestion.handle); + setContent(replacement.content); + setActiveMention(null); + setMentionSuggestions([]); + requestAnimationFrame(() => { + textareaRef.current?.focus(); + textareaRef.current?.setSelectionRange(replacement.caret, replacement.caret); + }); + }; + + const handleMentionKeyDown = (event: React.KeyboardEvent) => { + if (!activeMention || mentionSuggestions.length === 0) { + if (event.key === 'Escape') setActiveMention(null); + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + setSelectedMentionIndex((index) => (index + 1) % mentionSuggestions.length); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setSelectedMentionIndex((index) => (index - 1 + mentionSuggestions.length) % mentionSuggestions.length); + } else if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault(); + chooseMention(mentionSuggestions[selectedMentionIndex]); + } else if (event.key === 'Escape') { + event.preventDefault(); + setActiveMention(null); + setMentionSuggestions([]); + } + }; + const fetchPreview = async (url: string) => { - setFetchingPreview(true); try { const res = await fetch(`/api/media/preview?url=${encodeURIComponent(url)}`); if (res.ok) { @@ -104,8 +207,6 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold } } catch (err) { console.error('Preview error', err); - } finally { - setFetchingPreview(false); } }; @@ -120,7 +221,7 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold setIsPosting(true); try { - const posted = await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id, isNsfw); + const posted = await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview || undefined, replyingTo?.id, isNsfw); if (posted === false) { setIsPosting(false); return; @@ -236,14 +337,74 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold )} - setContent(e.target.value)} - maxLength={maxLength + 50} // Allow some overflow for better UX - autoFocus={autoFocus} - /> +
+ { + setContent(event.target.value); + syncActiveMention(event.target.value, event.target.selectionStart); + }} + onClick={(event) => syncActiveMention(event.currentTarget.value, event.currentTarget.selectionStart)} + onKeyUp={(event) => { + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) { + syncActiveMention(event.currentTarget.value, event.currentTarget.selectionStart); + } + }} + onKeyDown={handleMentionKeyDown} + onBlur={() => window.setTimeout(() => setActiveMention(null), 120)} + maxLength={maxLength + 50} // Allow some overflow for better UX + autoFocus={autoFocus} + role="combobox" + aria-autocomplete="list" + aria-expanded={Boolean(activeMention && (mentionLoading || mentionSuggestions.length > 0))} + aria-controls={mentionListId} + aria-activedescendant={mentionSuggestions[selectedMentionIndex] + ? `${mentionListId}-option-${selectedMentionIndex}` + : undefined} + /> + {activeMention && (mentionLoading || mentionSuggestions.length > 0) && ( +
+ {mentionLoading && mentionSuggestions.length === 0 ? ( +
Finding people…
+ ) : mentionSuggestions.map((suggestion, index) => ( + + ))} +
+ )} +
{attachments.length > 0 && (
{attachments.map((item) => { diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index d9c5b9d..0ad01f6 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -11,13 +11,14 @@ import { useToast } from '@/lib/contexts/ToastContext'; import { VideoEmbed } from '@/components/VideoEmbed'; import BlurredImage from '@/components/BlurredImage'; import BlurredVideo from '@/components/BlurredVideo'; -import { useFormattedHandle } from '@/lib/utils/handle'; +import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle'; import { useDomain } from '@/lib/contexts/ConfigContext'; import { signedAPI } from '@/lib/api/signed-fetch'; import type { LinkPreviewData } from '@/lib/media/linkPreview'; import { AvatarImage } from '@/components/AvatarImage'; import { AudioPlayer } from '@/components/AudioPlayer'; import { getMediaKind } from '@/lib/media/upload-policy'; +import { tokenizePostContent } from '@/lib/mentions/parser'; // Component for link preview image that hides on error function LinkPreviewImage({ src, alt }: { src: string; alt: string }) { @@ -584,9 +585,24 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, const renderContent = (content: string, hidePreviewUrl?: string) => { const decoded = decodeHtmlEntities(content); - const parts = decoded.split(/(https?:\/\/[^\s]+)/g); - return parts.map((part, index) => { - if (part.match(/^https?:\/\/[^\s]+$/)) { + const tokens = tokenizePostContent(decoded, domain); + return tokens.map((token, index) => { + if (token.type === 'mention') { + return ( + event.stopPropagation()} + title={token.isQualified ? token.raw : `@${token.handle}@${domain}`} + > + {token.raw} + + ); + } + + if (token.type === 'url') { + const part = token.value; // If this URL matches the link preview URL, hide it entirely if (hidePreviewUrl && part.includes(hidePreviewUrl.replace(/^https?:\/\/(www\.)?/, '').split('/')[0])) { return null; @@ -622,16 +638,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, ); } } - // Handle newlines - if (part.includes('\n')) { - return part.split('\n').map((line, lineIndex, arr) => ( - - {line} - {lineIndex < arr.length - 1 &&
} -
- )); - } - return {part}; + return {token.value}; }); }; @@ -651,7 +658,10 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, ? JSON.parse(post.swarmReplyToAuthor) : post.swarmReplyToAuthor)?.nodeDomain, } as Post : null); - const replyToHandle = effectiveReplyTo?.author?.handle ? useFormattedHandle(effectiveReplyTo.author.handle, effectiveReplyTo.nodeDomain) : ''; + const replyToHandle = useFormattedHandle( + effectiveReplyTo?.author?.handle || '', + effectiveReplyTo?.nodeDomain, + ); const repostHandle = useFormattedHandle(post.author.handle, post.nodeDomain); const hasOwnContent = decodeHtmlEntities(post.content).trim().length > 0; const isRepostEvent = Boolean(post.repostOf); diff --git a/src/db/relations.ts b/src/db/relations.ts index da826e3..0de8e15 100644 --- a/src/db/relations.ts +++ b/src/db/relations.ts @@ -56,6 +56,7 @@ export const relations = defineRelations(schema, (r) => ({ }), likes: r.many.likes({ from: r.posts.id, to: r.likes.postId }), media: r.many.media({ from: r.posts.id, to: r.media.postId }), + mentionDeliveries: r.many.mentionDeliveries({ from: r.posts.id, to: r.mentionDeliveries.postId }), }, media: { user: r.one.users({ from: r.media.userId, to: r.users.id, optional: false }), @@ -101,6 +102,13 @@ export const relations = defineRelations(schema, (r) => ({ }), post: r.one.posts({ from: r.notifications.postId, to: r.posts.id }), }, + mentionDeliveries: { + post: r.one.posts({ + from: r.mentionDeliveries.postId, + to: r.posts.id, + optional: false, + }), + }, sessions: { user: r.one.users({ from: r.sessions.userId, to: r.users.id, optional: false }), }, diff --git a/src/db/schema.ts b/src/db/schema.ts index d8efa15..abab44b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -409,13 +409,41 @@ export const notifications = sqliteTable('notifications', { targetIsBot: integer('target_is_bot', { mode: 'boolean' }), // Post reference postId: text('post_id').references(() => posts.id, { onDelete: 'cascade' }), + remotePostId: text('remote_post_id'), + remotePostDomain: text('remote_post_domain'), postContent: text('post_content'), // Cached content for display + interactionId: text('interaction_id'), // Idempotency key for local and federated interactions type: text('type').notNull(), // follow | like | repost | mention readAt: integer('read_at', { mode: 'timestamp' }), createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(), }, (table) => [ index('notifications_user_idx').on(table.userId), index('notifications_created_idx').on(table.createdAt), + uniqueIndex('notifications_interaction_unique_idx').on(table.interactionId), +]); + + +// ============================================ +// MENTION DELIVERY OUTBOX +// ============================================ + +export const mentionDeliveries = sqliteTable('mention_deliveries', { + id: text('id').primaryKey().$defaultFn(() => randomUUID()), + interactionId: text('interaction_id').notNull().unique(), + postId: text('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }), + targetHandle: text('target_handle').notNull(), + targetDomain: text('target_domain').notNull(), + status: text('status').default('pending').notNull(), // pending | processing | retry | delivered | dead + attempts: integer('attempts').default(0).notNull(), + nextAttemptAt: integer('next_attempt_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(), + lastAttemptAt: integer('last_attempt_at', { mode: 'timestamp' }), + deliveredAt: integer('delivered_at', { mode: 'timestamp' }), + lastError: text('last_error'), + createdAt: integer('created_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(), +}, (table) => [ + uniqueIndex('mention_deliveries_target_unique_idx').on(table.postId, table.targetHandle, table.targetDomain), + index('mention_deliveries_due_idx').on(table.status, table.nextAttemptAt), ]); @@ -633,6 +661,7 @@ export const botMentions = sqliteTable('bot_mentions', { index('bot_mentions_bot_idx').on(table.botId), index('bot_mentions_processed_idx').on(table.isProcessed), index('bot_mentions_created_idx').on(table.createdAt), + uniqueIndex('bot_mentions_bot_post_unique_idx').on(table.botId, table.postId), ]); diff --git a/src/lib/background/scheduler.ts b/src/lib/background/scheduler.ts index 83bd2ef..51f66c1 100644 --- a/src/lib/background/scheduler.ts +++ b/src/lib/background/scheduler.ts @@ -14,10 +14,13 @@ import { announceToSeeds } from '@/lib/swarm/discovery'; import { getSwarmStats } from '@/lib/swarm/registry'; import { syncRemoteFollowsPosts } from '@/lib/background/remote-sync'; import { isPublicSwarmDomain } from '@/lib/swarm/node-domain'; +import { processMentionDeliveryOutbox } from '@/lib/mentions/delivery'; +import { processAllActiveBotMentions } from '@/lib/bots/mentionHandler'; const BOT_INTERVAL_MS = 60 * 1000; // 1 minute const GOSSIP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes const REMOTE_SYNC_INTERVAL_MS = 60 * 1000; // 1 minute - keep feeds fresh +const MENTION_DELIVERY_INTERVAL_MS = 30 * 1000; const STARTUP_DELAY_MS = 10 * 1000; // Wait 10s for server to be ready let isStarted = false; @@ -33,6 +36,7 @@ function log(category: string, message: string, data?: unknown) { async function runBotTasks() { try { + const mentions = await processAllActiveBotMentions(); const results = await processAllAutonomousBots(); const posted = results.filter(r => r.result.posted).length; @@ -58,11 +62,25 @@ async function runBotTasks() { const errorMsgs = results.filter(r => r.error).map(r => `${r.botHandle}: ${r.error}`); log('BOTS', `Errors: ${errorMsgs.join('; ')}`); } + if (mentions.detected > 0 || mentions.responded > 0 || mentions.failed > 0) { + log('BOTS', `Mentions: ${mentions.detected} detected, ${mentions.responded} responded, ${mentions.failed} failed`); + } } catch (error) { log('BOTS', `Error: ${error}`); } } +async function runMentionDeliveries() { + try { + const result = await processMentionDeliveryOutbox(); + if (result.delivered > 0 || result.retried > 0 || result.dead > 0) { + log('MENTIONS', `Delivered ${result.delivered}, retrying ${result.retried}, dead-lettered ${result.dead}`); + } + } catch (error) { + log('MENTIONS', `Outbox error: ${error}`); + } +} + async function runSwarmGossip() { try { const stats = await getSwarmStats(); @@ -140,12 +158,14 @@ export function startBackgroundTasks(origin?: string) { // Run initial bot check await runBotTasks(); + await runMentionDeliveries(); // Run initial remote sync (after 15s to let server stabilize) setTimeout(() => runRemoteSync(syncOrigin), 15 * 1000); // Schedule recurring tasks setInterval(runBotTasks, BOT_INTERVAL_MS); + setInterval(runMentionDeliveries, MENTION_DELIVERY_INTERVAL_MS); if (publicSwarmEnabled) { setInterval(runSwarmGossip, GOSSIP_INTERVAL_MS); } diff --git a/src/lib/bots/mentionHandler.process.test.ts b/src/lib/bots/mentionHandler.process.test.ts new file mode 100644 index 0000000..f8303d7 --- /dev/null +++ b/src/lib/bots/mentionHandler.process.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const mention = { + id: '550e8400-e29b-41d4-a716-446655440000', + botId: 'bot-id', + postId: 'source-post-id', + authorId: 'author-id', + content: 'Hello @helperbot', + isProcessed: false, + processedAt: null as Date | null, + responsePostId: null as string | null, + isRemote: false, + remoteActorUrl: null, + createdAt: new Date('2026-07-15T20:00:00.000Z'), + }; + return { + mention, + generateReply: vi.fn(), + registerPostMentions: vi.fn(), + recordReply: vi.fn(), + deliverPost: vi.fn(), + transaction: vi.fn(), + }; +}); + +const tables = vi.hoisted(() => ({ + botMentions: { id: 'botMentions.id', isProcessed: 'botMentions.isProcessed', responsePostId: 'botMentions.responsePostId', processedAt: 'botMentions.processedAt' }, + notifications: { id: 'notifications.id' }, + posts: { id: 'posts.id', repliesCount: 'posts.repliesCount' }, + users: { id: 'users.id', postsCount: 'users.postsCount' }, +})); + +vi.mock('drizzle-orm', () => ({ + and: (...values: unknown[]) => values, + eq: (...values: unknown[]) => values, + isNull: (value: unknown) => value, + lte: (...values: unknown[]) => values, + or: (...values: unknown[]) => values, + sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values }), +})); + +vi.mock('@/db', () => { + const sourcePost = { + id: 'source-post-id', + userId: 'author-id', + content: 'Hello @helperbot', + replyToId: null, + createdAt: new Date('2026-07-15T20:00:00.000Z'), + author: { handle: 'author', displayName: 'Author' }, + }; + const responsePost = { + id: mocks.mention.id, + userId: 'bot-user-id', + content: 'A useful response', + replyToId: sourcePost.id, + createdAt: new Date('2026-07-15T20:01:00.000Z'), + isNsfw: false, + }; + const transactionClient = { + insert: vi.fn((table: unknown) => ({ + values: vi.fn(() => table === tables.posts + ? { + onConflictDoNothing: () => ({ returning: vi.fn().mockResolvedValue([responsePost]) }), + } + : { + onConflictDoNothing: vi.fn().mockResolvedValue([]), + }), + })), + update: vi.fn((table: unknown) => ({ + set: vi.fn((values: Record) => ({ + where: vi.fn(async () => { + if (table === tables.botMentions && typeof values.responsePostId === 'string') { + mocks.mention.isProcessed = true; + mocks.mention.processedAt = values.processedAt as Date; + mocks.mention.responsePostId = values.responsePostId; + } + }), + })), + })), + select: vi.fn(() => ({ + from: () => ({ where: () => ({ limit: vi.fn().mockResolvedValue([]) }) }), + })), + }; + const db = { + query: { + botMentions: { + findFirst: vi.fn(async () => ({ ...mocks.mention })), + }, + bots: { + findFirst: vi.fn(async () => ({ + id: 'bot-id', + name: 'Helper', + personalityConfig: JSON.stringify({ systemPrompt: 'Be useful', temperature: 0.5, maxTokens: 200 }), + llmProvider: 'openai', + llmModel: 'gpt-4o-mini', + llmEndpoint: null, + llmApiKeyEncrypted: 'encrypted', + user: { + id: 'bot-user-id', + handle: 'helperbot', + displayName: 'Helper Bot', + avatarUrl: null, + isNsfw: false, + did: 'did:synapsis:helperbot', + publicKey: 'public-key', + }, + })), + }, + posts: { + findFirst: vi.fn(async () => sourcePost), + }, + }, + update: vi.fn(() => ({ + set: vi.fn((values: Record) => ({ + where: vi.fn(() => { + let claimed = false; + if (values.isProcessed === true && mocks.mention.isProcessed === false) { + claimed = true; + mocks.mention.isProcessed = true; + mocks.mention.processedAt = values.processedAt as Date; + } else if (values.isProcessed === false) { + mocks.mention.isProcessed = false; + mocks.mention.processedAt = null; + } + return { + returning: vi.fn().mockResolvedValue(claimed ? [{ id: mocks.mention.id }] : []), + then: (resolve: (value: unknown) => unknown) => Promise.resolve(undefined).then(resolve), + catch: (reject: (reason: unknown) => unknown) => Promise.resolve(undefined).catch(reject), + }; + }), + })), + })), + transaction: mocks.transaction.mockImplementation(async (callback: (tx: typeof transactionClient) => Promise) => callback(transactionClient)), + }; + return { db, ...tables }; +}); + +vi.mock('./contentGenerator', () => ({ + ContentGenerator: class ContentGenerator { + generateReply = mocks.generateReply; + }, +})); + +vi.mock('./rateLimiter', () => ({ + canReply: vi.fn().mockResolvedValue({ allowed: true }), + recordReply: mocks.recordReply, +})); + +vi.mock('@/lib/mentions/delivery', () => ({ + registerPostMentions: mocks.registerPostMentions, +})); + +vi.mock('@/lib/swarm/interactions', () => ({ + deliverPostToSwarmFollowers: mocks.deliverPost, +})); + +import { processMention } from './mentionHandler'; + +describe('bot mention processing', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.mention.isProcessed = false; + mocks.mention.processedAt = null; + mocks.mention.responsePostId = null; + mocks.generateReply.mockResolvedValue({ text: 'A useful response', tokensUsed: 12, model: 'test' }); + mocks.registerPostMentions.mockResolvedValue({ localNotifications: 0, remoteQueued: 0, skipped: 0 }); + mocks.recordReply.mockResolvedValue(undefined); + mocks.deliverPost.mockResolvedValue({ delivered: 0, failed: 0 }); + }); + + it('persists one deterministic reply and returns it on a repeated request', async () => { + const first = await processMention(mocks.mention.id); + const second = await processMention(mocks.mention.id); + + expect(first).toEqual({ success: true, responsePostId: mocks.mention.id }); + expect(second).toEqual(first); + expect(mocks.transaction).toHaveBeenCalledOnce(); + expect(mocks.generateReply).toHaveBeenCalledOnce(); + expect(mocks.registerPostMentions).toHaveBeenCalledOnce(); + }); + + it('rejects a concurrent processor while the first owns the lease', async () => { + let finishGeneration!: (value: { text: string; tokensUsed: number; model: string }) => void; + mocks.generateReply.mockReturnValue(new Promise((resolve) => { + finishGeneration = resolve; + })); + + const firstRequest = processMention(mocks.mention.id); + await vi.waitFor(() => expect(mocks.generateReply).toHaveBeenCalledOnce()); + + await expect(processMention(mocks.mention.id)).resolves.toEqual({ + success: false, + error: 'Mention is already being processed', + }); + + finishGeneration({ text: 'A useful response', tokensUsed: 12, model: 'test' }); + await expect(firstRequest).resolves.toEqual({ + success: true, + responsePostId: mocks.mention.id, + }); + expect(mocks.transaction).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/bots/mentionHandler.property.test.ts b/src/lib/bots/mentionHandler.property.test.ts index e5652ae..6678a7d 100644 --- a/src/lib/bots/mentionHandler.property.test.ts +++ b/src/lib/bots/mentionHandler.property.test.ts @@ -226,9 +226,13 @@ vi.mock('@/db', () => { responsePostId: null, }; mentionsStore.set(id, mention); - return { + const result = { returning: vi.fn().mockResolvedValue([mention]), }; + return { + ...result, + onConflictDoNothing: vi.fn().mockReturnValue(result), + }; } else { // This is a post insert const id = `post-${++postIdCounter}`; diff --git a/src/lib/bots/mentionHandler.ts b/src/lib/bots/mentionHandler.ts index d93c4f4..62590f1 100644 --- a/src/lib/bots/mentionHandler.ts +++ b/src/lib/bots/mentionHandler.ts @@ -7,11 +7,16 @@ * Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6 */ -import { db, bots, botMentions, posts, users } from '@/db'; -import { eq, and, desc, asc, isNull } from 'drizzle-orm'; +import { db, botMentions, notifications, posts, users } from '@/db'; +import { and, eq, isNull, lte, or, sql } from 'drizzle-orm'; import { ContentGenerator, type Bot as GeneratorBot, type Post as GeneratorPost } from './contentGenerator'; import { canReply, recordReply } from './rateLimiter'; -import { decryptApiKey } from './encryption'; +import type { LLMProvider } from './encryption'; +import { parseMentions, uniqueMentions } from '@/lib/mentions/parser'; +import { registerPostMentions } from '@/lib/mentions/delivery'; +import { deliverPostToSwarmFollowers } from '@/lib/swarm/interactions'; + +const MENTION_PROCESSING_LEASE_MS = 10 * 60 * 1000; // ============================================ // TYPES @@ -50,6 +55,18 @@ export interface PostWithAuthor { }; } +interface ConversationPostRow { + id: string; + userId: string; + content: string; + replyToId: string | null; + createdAt: Date; + author: { + handle: string; + displayName: string | null; + }; +} + /** * Mention detection result. */ @@ -109,7 +126,7 @@ export async function detectMentions(botId: string): Promise m.postId)); - // Find posts that mention the bot's handle - // Note: In a production system, this would be more efficient with full-text search - // or a dedicated mentions table updated on post creation - const mentionPattern = `@${bot.user.handle}`; - - // Get recent posts (last 24 hours) that might contain mentions - const oneDayAgo = new Date(); - oneDayAgo.setDate(oneDayAgo.getDate() - 1); - + // Direct post creation records bot mentions immediately. This bounded scan + // remains as a repair path for imported posts and older nodes. const recentPosts = await db.query.posts.findMany({ where: { AND: [{ isRemoved: false }] }, with: { @@ -155,9 +165,12 @@ export async function detectMentions(botId: string): Promise - post.content.includes(mentionPattern) && - !existingPostIds.has(post.id) + const newMentionPosts = recentPosts.filter((post) => + post.userId !== bot.user.id + && !existingPostIds.has(post.id) + && uniqueMentions(parseMentions(post.content)).some( + (mention) => mention.isLocal && mention.handle === bot.user.handle.toLowerCase(), + ) ); // Create mention records @@ -171,7 +184,9 @@ export async function detectMentions(botId: string): Promise { + let ownsLease = false; + let completed = false; try { // Get the mention const mention = await db.query.botMentions.findFirst({ @@ -370,13 +387,45 @@ export async function processMention(mentionId: string): Promise { + const [createdPost] = await tx.insert(posts).values({ + id: postUuid, + userId: bot.user.id, // Bot posts as its associated user + content: generatedReply.text, + replyToId: mention.postId, + isNsfw: bot.user.isNsfw, + apId: `https://${nodeDomain}/posts/${postUuid}`, + apUrl: `https://${nodeDomain}/posts/${postUuid}`, + }).onConflictDoNothing().returning(); + const [existingPost] = createdPost ? [] : await tx.select().from(posts) + .where(eq(posts.id, postUuid)) + .limit(1); + const reply = createdPost || existingPost; + if (!reply || reply.userId !== bot.user.id || reply.replyToId !== mention.postId) { + throw new MentionHandlerError('Unable to persist bot reply', 'DATABASE_ERROR'); + } - // Mark mention as processed - await db.update(botMentions) - .set({ - isProcessed: true, - processedAt: new Date(), - responsePostId: responsePost.id, - }) - .where(eq(botMentions.id, mentionId)); + if (!createdPost) { + await tx.update(botMentions).set({ + isProcessed: true, + processedAt: new Date(), + responsePostId: reply.id, + }).where(eq(botMentions.id, mentionId)); + return reply; + } + + await tx.update(users) + .set({ postsCount: sql`${users.postsCount} + 1` }) + .where(eq(users.id, bot.user.id)); + await tx.update(posts) + .set({ repliesCount: sql`${posts.repliesCount} + 1` }) + .where(eq(posts.id, mention.postId)); + await tx.insert(notifications).values({ + userId: mention.authorId, + actorId: bot.user.id, + actorHandle: bot.user.handle, + actorDisplayName: bot.user.displayName, + actorAvatarUrl: bot.user.avatarUrl, + actorNodeDomain: null, + postId: reply.id, + postContent: reply.content.slice(0, 200), + interactionId: `bot-reply:${mention.id}`, + type: 'reply', + }).onConflictDoNothing(); + await tx.update(botMentions) + .set({ + isProcessed: true, + processedAt: new Date(), + responsePostId: reply.id, + }) + .where(eq(botMentions.id, mentionId)); + return reply; + }); + completed = true; + + try { + await registerPostMentions({ + postId: responsePost.id, + content: responsePost.content, + actor: { + id: bot.user.id, + handle: bot.user.handle, + displayName: bot.user.displayName, + avatarUrl: bot.user.avatarUrl, + did: bot.user.did, + publicKey: bot.user.publicKey, + }, + nodeDomain, + }); + } catch (error) { + console.error('[Bots] Failed to register mentions in bot reply:', error); + } // Record reply for rate limiting - await recordReply(mention.botId); + try { + await recordReply(mention.botId); + } catch (error) { + console.error('[Bots] Failed to record mention reply rate limit:', error); + } + + void deliverPostToSwarmFollowers( + bot.user.id, + responsePost, + { + handle: bot.user.handle, + displayName: bot.user.displayName, + avatarUrl: bot.user.avatarUrl, + isNsfw: bot.user.isNsfw, + }, + [], + nodeDomain, + ).catch((error) => console.error('[Bots] Failed to federate mention reply:', error)); return { success: true, responsePostId: responsePost.id, }; } catch (error) { + if (ownsLease && !completed) { + await db.update(botMentions).set({ + isProcessed: false, + processedAt: null, + }).where(and( + eq(botMentions.id, mentionId), + isNull(botMentions.responsePostId), + )).catch((releaseError) => { + console.error(`[Bots] Failed to release mention claim ${mentionId}:`, releaseError); + }); + } if (error instanceof MentionHandlerError) { return { success: false, @@ -539,6 +682,42 @@ export async function processAllMentions(botId: string): Promise { + const staleBefore = new Date(Date.now() - MENTION_PROCESSING_LEASE_MS); + await db.update(botMentions).set({ isProcessed: false, processedAt: null }) + .where(and( + eq(botMentions.isProcessed, true), + isNull(botMentions.responsePostId), + or(isNull(botMentions.processedAt), lte(botMentions.processedAt, staleBefore)), + )); + + const activeBots = await db.query.bots.findMany({ + where: { AND: [{ isActive: true }, { isSuspended: false }] }, + columns: { id: true }, + }); + const totals = { bots: activeBots.length, detected: 0, responded: 0, failed: 0 }; + + for (const bot of activeBots) { + try { + const detected = await detectMentions(bot.id); + totals.detected += detected.mentions.length; + const responses = await processAllMentions(bot.id); + totals.responded += responses.filter((response) => response.success).length; + totals.failed += responses.filter((response) => !response.success).length; + } catch (error) { + totals.failed += 1; + console.error(`[Bots] Mention processing failed for ${bot.id}:`, error); + } + } + return totals; +} + // ============================================ // MENTION STORAGE // ============================================ diff --git a/src/lib/mentions/delivery.test.ts b/src/lib/mentions/delivery.test.ts new file mode 100644 index 0000000..a6bce2e --- /dev/null +++ b/src/lib/mentions/delivery.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; + +import { mentionRetryDelayMs } from './delivery'; + +describe('mention delivery retry schedule', () => { + it('uses bounded exponential backoff', () => { + expect(mentionRetryDelayMs(1)).toBe(30_000); + expect(mentionRetryDelayMs(2)).toBe(60_000); + expect(mentionRetryDelayMs(3)).toBe(120_000); + expect(mentionRetryDelayMs(20)).toBe(3_600_000); + }); +}); diff --git a/src/lib/mentions/delivery.ts b/src/lib/mentions/delivery.ts new file mode 100644 index 0000000..a356c89 --- /dev/null +++ b/src/lib/mentions/delivery.ts @@ -0,0 +1,318 @@ +import { randomUUID } from 'node:crypto'; +import { and, eq, lte, or } from 'drizzle-orm'; + +import { + botMentions, + db, + mentionDeliveries, + notifications, +} from '@/db'; +import { buildNotificationTarget } from '@/lib/notifications'; +import { discoverNode } from '@/lib/swarm/discovery'; +import { + deliverSwarmMention, + isSwarmNode, + type SwarmInteractionResponse, +} from '@/lib/swarm/interactions'; +import { normalizeNodeDomain } from '@/lib/swarm/node-domain'; +import { parseMentions, uniqueMentions } from './parser'; + +const MAX_DELIVERY_ATTEMPTS = 12; +const PROCESSING_LEASE_MS = 2 * 60 * 1000; +let activeWorker: Promise | null = null; + +export interface MentionActor { + id: string; + handle: string; + displayName: string | null; + avatarUrl: string | null; + did?: string | null; + publicKey?: string | null; +} + +export interface RegisterPostMentionsInput { + postId: string; + content: string; + actor: MentionActor; + nodeDomain?: string; +} + +export interface RegisterPostMentionsResult { + localNotifications: number; + remoteQueued: number; + skipped: number; +} + +export interface MentionOutboxResult { + delivered: number; + retried: number; + dead: number; +} + +export function mentionRetryDelayMs(attempt: number): number { + return Math.min(60 * 60 * 1000, 30 * 1000 * (2 ** Math.max(0, attempt - 1))); +} + +async function localInteractionAllowed(recipientId: string, actorId: string): Promise { + const [block, mute] = await Promise.all([ + db.query.blocks.findFirst({ + where: { + OR: [ + { AND: [{ userId: recipientId }, { blockedUserId: actorId }] }, + { AND: [{ userId: actorId }, { blockedUserId: recipientId }] }, + ], + }, + columns: { id: true }, + }), + db.query.mutes.findFirst({ + where: { AND: [{ userId: recipientId }, { mutedUserId: actorId }] }, + columns: { id: true }, + }), + ]); + return !block && !mute; +} + +async function insertMentionNotification(values: typeof notifications.$inferInsert): Promise { + const inserted = await db.insert(notifications) + .values(values) + .onConflictDoNothing() + .returning({ id: notifications.id }); + return inserted.length > 0; +} + +async function registerLocalBotMention( + mentionedUserId: string, + postId: string, + actorId: string, + content: string, +): Promise { + const bot = await db.query.bots.findFirst({ + where: { userId: mentionedUserId }, + columns: { id: true, isActive: true, isSuspended: true }, + }); + if (!bot || !bot.isActive || bot.isSuspended) return; + + await db.insert(botMentions).values({ + botId: bot.id, + postId, + authorId: actorId, + content, + isProcessed: false, + isRemote: false, + }).onConflictDoNothing(); +} + +/** Resolve local mentions synchronously and persist remote delivery work. */ +export async function registerPostMentions( + input: RegisterPostMentionsInput, +): Promise { + const nodeDomain = normalizeNodeDomain( + input.nodeDomain || process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821', + ); + const mentions = uniqueMentions(parseMentions(input.content, nodeDomain)); + const result: RegisterPostMentionsResult = { + localNotifications: 0, + remoteQueued: 0, + skipped: 0, + }; + + for (const mention of mentions) { + if (mention.isLocal) { + const mentionedUser = await db.query.users.findFirst({ + where: { handle: mention.handle }, + }); + if (!mentionedUser + || mentionedUser.id === input.actor.id + || mentionedUser.isSuspended + || !(await localInteractionAllowed(mentionedUser.id, input.actor.id))) { + result.skipped += 1; + continue; + } + + const created = await insertMentionNotification({ + userId: mentionedUser.id, + actorId: input.actor.id, + actorHandle: input.actor.handle, + actorDisplayName: input.actor.displayName, + actorAvatarUrl: input.actor.avatarUrl, + actorNodeDomain: null, + postId: input.postId, + postContent: input.content.slice(0, 200), + interactionId: `mention:local:${input.postId}:${mentionedUser.id}`, + ...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}), + type: 'mention', + }); + if (created) result.localNotifications += 1; + + if (mentionedUser.isBot) { + await registerLocalBotMention(mentionedUser.id, input.postId, input.actor.id, input.content); + } + + if (mentionedUser.isBot + && mentionedUser.botOwnerId + && await localInteractionAllowed(mentionedUser.botOwnerId, input.actor.id)) { + const ownerCreated = await insertMentionNotification({ + userId: mentionedUser.botOwnerId, + actorId: input.actor.id, + actorHandle: input.actor.handle, + actorDisplayName: input.actor.displayName, + actorAvatarUrl: input.actor.avatarUrl, + actorNodeDomain: null, + postId: input.postId, + postContent: input.content.slice(0, 200), + interactionId: `mention:local-owner:${input.postId}:${mentionedUser.id}`, + ...buildNotificationTarget(mentionedUser), + type: 'mention', + }); + if (ownerCreated) result.localNotifications += 1; + } + continue; + } + + if (!mention.domain) { + result.skipped += 1; + continue; + } + + const inserted = await db.insert(mentionDeliveries).values({ + interactionId: randomUUID(), + postId: input.postId, + targetHandle: mention.handle, + targetDomain: normalizeNodeDomain(mention.domain), + status: 'pending', + nextAttemptAt: new Date(), + }).onConflictDoNothing().returning({ id: mentionDeliveries.id }); + if (inserted.length > 0) result.remoteQueued += 1; + } + + if (result.remoteQueued > 0) { + void processMentionDeliveryOutbox().catch((error) => { + console.error('[Mentions] Immediate outbox processing failed:', error); + }); + } + return result; +} + +async function markDeliveryFailure( + delivery: typeof mentionDeliveries.$inferSelect, + response: SwarmInteractionResponse, +): Promise<'retry' | 'dead'> { + const attempts = delivery.attempts + 1; + const isDead = response.retryable === false || attempts >= MAX_DELIVERY_ATTEMPTS; + const now = new Date(); + await db.update(mentionDeliveries).set({ + status: isDead ? 'dead' : 'retry', + attempts, + nextAttemptAt: isDead ? now : new Date(now.getTime() + mentionRetryDelayMs(attempts)), + lastError: (response.error || 'Unknown delivery failure').slice(0, 1000), + updatedAt: now, + }).where(eq(mentionDeliveries.id, delivery.id)); + return isDead ? 'dead' : 'retry'; +} + +async function attemptMentionDelivery( + delivery: typeof mentionDeliveries.$inferSelect, +): Promise<'delivered' | 'retry' | 'dead'> { + const post = await db.query.posts.findFirst({ + where: { id: delivery.postId }, + with: { author: true }, + }); + if (!post || post.isRemoved || post.author.isSuspended) { + return markDeliveryFailure(delivery, { + success: false, + retryable: false, + error: 'Source post or actor is unavailable', + }); + } + + let knownNode = await isSwarmNode(delivery.targetDomain); + if (!knownNode) knownNode = (await discoverNode(delivery.targetDomain)).success; + if (!knownNode) { + return markDeliveryFailure(delivery, { + success: false, + retryable: true, + error: `Unable to discover Synapsis node ${delivery.targetDomain}`, + }); + } + + const response = await deliverSwarmMention(delivery.targetDomain, { + mentionedHandle: delivery.targetHandle, + mention: { + actorHandle: post.author.handle, + actorDisplayName: post.author.displayName || post.author.handle, + actorAvatarUrl: post.author.avatarUrl || undefined, + actorNodeDomain: normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821'), + actorDid: post.author.did, + actorPublicKey: post.author.publicKey, + postId: post.id, + postContent: post.content, + interactionId: delivery.interactionId, + timestamp: post.createdAt.toISOString(), + }, + }); + + if (!response.success) return markDeliveryFailure(delivery, response); + + await db.update(mentionDeliveries).set({ + status: 'delivered', + attempts: delivery.attempts + 1, + deliveredAt: new Date(), + lastError: null, + updatedAt: new Date(), + }).where(eq(mentionDeliveries.id, delivery.id)); + return 'delivered'; +} + +async function runMentionDeliveryOutbox(limit: number): Promise { + const result: MentionOutboxResult = { delivered: 0, retried: 0, dead: 0 }; + const now = new Date(); + const staleBefore = new Date(now.getTime() - PROCESSING_LEASE_MS); + + await db.update(mentionDeliveries).set({ status: 'retry', nextAttemptAt: now, updatedAt: now }) + .where(and( + eq(mentionDeliveries.status, 'processing'), + lte(mentionDeliveries.lastAttemptAt, staleBefore), + )); + + const due = await db.select().from(mentionDeliveries) + .where(and( + or(eq(mentionDeliveries.status, 'pending'), eq(mentionDeliveries.status, 'retry')), + lte(mentionDeliveries.nextAttemptAt, now), + )) + .limit(Math.max(1, Math.min(limit, 100))); + + for (const delivery of due) { + const claimed = await db.update(mentionDeliveries).set({ + status: 'processing', + lastAttemptAt: new Date(), + updatedAt: new Date(), + }).where(and( + eq(mentionDeliveries.id, delivery.id), + or(eq(mentionDeliveries.status, 'pending'), eq(mentionDeliveries.status, 'retry')), + )).returning({ id: mentionDeliveries.id }); + if (claimed.length === 0) continue; + + try { + const state = await attemptMentionDelivery(delivery); + result[state === 'retry' ? 'retried' : state] += 1; + } catch (error) { + const state = await markDeliveryFailure(delivery, { + success: false, + retryable: true, + error: error instanceof Error ? error.message : String(error), + }); + result[state === 'dead' ? 'dead' : 'retried'] += 1; + } + } + return result; +} + +export async function processMentionDeliveryOutbox(limit = 25): Promise { + if (activeWorker) return activeWorker; + activeWorker = runMentionDeliveryOutbox(limit); + try { + return await activeWorker; + } finally { + activeWorker = null; + } +} diff --git a/src/lib/mentions/parser.test.ts b/src/lib/mentions/parser.test.ts new file mode 100644 index 0000000..6dffc3f --- /dev/null +++ b/src/lib/mentions/parser.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { + getActiveMentionQuery, + parseMentions, + replaceMentionQuery, + tokenizePostContent, + uniqueMentions, +} from './parser'; + +describe('mention parser', () => { + it('parses local, remote, and same-node qualified handles', () => { + const mentions = parseMentions( + 'Hi @Alice, @bob@remote.example and @carol@local.example.', + 'local.example', + ); + + expect(mentions.map(({ canonicalHandle, isLocal, raw }) => ({ canonicalHandle, isLocal, raw }))).toEqual([ + { canonicalHandle: 'alice', isLocal: true, raw: '@Alice' }, + { canonicalHandle: 'bob@remote.example', isLocal: false, raw: '@bob@remote.example' }, + { canonicalHandle: 'carol', isLocal: true, raw: '@carol@local.example' }, + ]); + }); + + it('rejects email addresses, URL path fragments, malformed domains, and overlong handles', () => { + const content = [ + 'mail alice@example.com', + 'visit https://example.com/@alice', + '@alice@bad_domain', + '@abcdefghijklmnopqrstuvwxyzabcde', + '@alice.example', + ].join(' '); + + expect(parseMentions(content, 'local.example')).toEqual([]); + }); + + it('keeps sentence punctuation outside the mention range', () => { + const [mention] = parseMentions('Hello (@alice@remote.example).'); + expect(mention.raw).toBe('@alice@remote.example'); + expect(mention.end).toBe('Hello (@alice@remote.example'.length); + }); + + it('supports the full bot handle length', () => { + const handle = 'b'.repeat(30); + expect(parseMentions(`Ask @${handle}`)[0]).toMatchObject({ handle }); + }); + + it('deduplicates aliases that resolve to the same local account', () => { + const mentions = parseMentions('@alice @Alice@local.example @bob@remote.example @bob@remote.example', 'local.example'); + expect(uniqueMentions(mentions).map((mention) => mention.canonicalHandle)).toEqual([ + 'alice', + 'bob@remote.example', + ]); + }); +}); + +describe('composer mention queries', () => { + it('finds local and qualified queries at the caret', () => { + expect(getActiveMentionQuery('hello @ali', 10)).toMatchObject({ + start: 6, + query: 'ali', + handleQuery: 'ali', + domainQuery: null, + }); + expect(getActiveMentionQuery('@alice@remote.ex', 16)).toMatchObject({ + start: 0, + query: 'alice@remote.ex', + handleQuery: 'alice', + domainQuery: 'remote.ex', + }); + }); + + it('does not activate inside an email address or URL', () => { + expect(getActiveMentionQuery('alice@example', 13)).toBeNull(); + expect(getActiveMentionQuery('https://example.com/@ali', 24)).toBeNull(); + }); + + it('replaces only the active query and returns the next caret position', () => { + const active = getActiveMentionQuery('Hello @ali friend', 10)!; + expect(replaceMentionQuery('Hello @ali friend', active, 'alice')).toEqual({ + content: 'Hello @alice friend', + caret: 12, + }); + }); +}); + +describe('rich text tokenization', () => { + it('emits clickable ranges without interpreting mentions inside URLs', () => { + const tokens = tokenizePostContent('See https://example.com/@alice and ask @bob.', 'local.example'); + expect(tokens.filter((token) => token.type !== 'text').map((token) => ({ type: token.type, value: token.value }))).toEqual([ + { type: 'url', value: 'https://example.com/@alice' }, + { type: 'mention', value: '@bob' }, + ]); + }); +}); diff --git a/src/lib/mentions/parser.ts b/src/lib/mentions/parser.ts new file mode 100644 index 0000000..b061e8e --- /dev/null +++ b/src/lib/mentions/parser.ts @@ -0,0 +1,216 @@ +import { resolveUserHandle } from '@/lib/swarm/user-handle'; +import { isValidNodeDomain } from '@/lib/utils/federation'; + +const HANDLE_PATTERN = /^[a-zA-Z0-9_]{3,30}$/; +const HANDLE_CHARACTER = /[a-zA-Z0-9_]/; +const MENTION_PATTERN = /@([a-zA-Z0-9_]{3,30})(?:@([a-zA-Z0-9.-]+(?::\d{1,5})?))?/g; +const URL_PATTERN = /https?:\/\/[^\s<]+/gi; + +export interface ParsedMention { + raw: string; + start: number; + end: number; + handle: string; + domain: string | null; + canonicalHandle: string; + isQualified: boolean; + isLocal: boolean; +} + +export interface ActiveMentionQuery { + start: number; + end: number; + query: string; + handleQuery: string; + domainQuery: string | null; +} + +export type RichTextToken = + | { type: 'text'; value: string; start: number; end: number } + | { type: 'url'; value: string; start: number; end: number } + | ({ type: 'mention'; value: string } & ParsedMention); + +function hasValidLeadingBoundary(content: string, start: number): boolean { + if (start === 0) return true; + return !/[a-zA-Z0-9_@.]/.test(content[start - 1]); +} + +function isInsideUrlLikeToken(content: string, start: number): boolean { + const tokenStart = Math.max( + content.lastIndexOf(' ', start - 1), + content.lastIndexOf('\n', start - 1), + content.lastIndexOf('\t', start - 1), + ) + 1; + const prefix = content.slice(tokenStart, start).toLowerCase(); + return prefix.includes('://') || prefix.startsWith('mailto:'); +} + +function normalizeMatchedDomain(value: string): string | null { + let candidate = value.toLowerCase(); + while (candidate.endsWith('.') && !isValidNodeDomain(candidate)) { + candidate = candidate.slice(0, -1); + } + return isValidNodeDomain(candidate) ? candidate : null; +} + +/** + * Parse valid local and federated mentions with source ranges. + * + * The parser deliberately rejects email addresses, mentions embedded in URLs, + * overlong handles, malformed domains, and partial qualified handles. Ranges + * exclude sentence punctuation so rendering and composer replacement remain + * exact. + */ +export function parseMentions( + content: string, + currentDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821', +): ParsedMention[] { + const mentions: ParsedMention[] = []; + MENTION_PATTERN.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = MENTION_PATTERN.exec(content)) !== null) { + const start = match.index; + const handle = match[1].toLowerCase(); + if (!HANDLE_PATTERN.test(handle) + || !hasValidLeadingBoundary(content, start) + || isInsideUrlLikeToken(content, start)) { + continue; + } + + const handleEnd = start + 1 + match[1].length; + const rawDomain = match[2]; + const domain = rawDomain ? normalizeMatchedDomain(rawDomain) : null; + + // An explicit but malformed/partial domain must never fall back to a local + // mention. That would notify the wrong person while the author is typing. + if (rawDomain && !domain) continue; + if (!rawDomain && content[handleEnd] === '@') continue; + + const end = domain + ? handleEnd + 1 + domain.length + : handleEnd; + const next = content[end]; + + if (next && HANDLE_CHARACTER.test(next)) continue; + if (!domain && next === '.' && /[a-zA-Z0-9]/.test(content[end + 1] || '')) continue; + + const qualified = domain ? `${handle}@${domain}` : handle; + const resolution = resolveUserHandle(qualified, currentDomain); + + mentions.push({ + raw: content.slice(start, end), + start, + end, + handle, + domain, + canonicalHandle: resolution.canonicalHandle, + isQualified: Boolean(domain), + isLocal: resolution.isLocal, + }); + } + + return mentions; +} + +export function uniqueMentions(mentions: readonly ParsedMention[]): ParsedMention[] { + const seen = new Set(); + return mentions.filter((mention) => { + const key = mention.canonicalHandle.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +/** Return the mention fragment immediately before the caret, if any. */ +export function getActiveMentionQuery(content: string, caret: number): ActiveMentionQuery | null { + const safeCaret = Math.max(0, Math.min(caret, content.length)); + const beforeCaret = content.slice(0, safeCaret); + const match = beforeCaret.match(/(?:^|[^a-zA-Z0-9_@.])@([a-zA-Z0-9_]{0,30})(?:@([a-zA-Z0-9.-]*(?::\d{0,5})?))?$/); + if (!match || match.index === undefined) return null; + + const boundaryLength = match[0].startsWith('@') ? 0 : 1; + const start = match.index + boundaryLength; + if (isInsideUrlLikeToken(content, start)) return null; + + const handleQuery = match[1] || ''; + const domainQuery = match[2] === undefined ? null : match[2]; + return { + start, + end: safeCaret, + query: domainQuery === null ? handleQuery : `${handleQuery}@${domainQuery}`, + handleQuery, + domainQuery, + }; +} + +export function replaceMentionQuery( + content: string, + active: ActiveMentionQuery, + replacement: string, +): { content: string; caret: number } { + const normalizedReplacement = replacement.startsWith('@') ? replacement : `@${replacement}`; + const needsSpace = active.end >= content.length || !/^\s/.test(content[active.end]); + const inserted = `${normalizedReplacement}${needsSpace ? ' ' : ''}`; + return { + content: `${content.slice(0, active.start)}${inserted}${content.slice(active.end)}`, + caret: active.start + inserted.length, + }; +} + +function trimUrlEnd(value: string): string { + let result = value; + while (/[.,!?;:]$/.test(result)) result = result.slice(0, -1); + while (result.endsWith(')')) { + const opens = (result.match(/\(/g) || []).length; + const closes = (result.match(/\)/g) || []).length; + if (closes <= opens) break; + result = result.slice(0, -1); + } + return result; +} + +/** Tokenize post text while giving URLs precedence over mentions inside URLs. */ +export function tokenizePostContent( + content: string, + currentDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821', +): RichTextToken[] { + const ranges: Array = []; + URL_PATTERN.lastIndex = 0; + let urlMatch: RegExpExecArray | null; + while ((urlMatch = URL_PATTERN.exec(content)) !== null) { + const value = trimUrlEnd(urlMatch[0]); + if (!value) continue; + ranges.push({ + type: 'url', + value, + start: urlMatch.index, + end: urlMatch.index + value.length, + }); + } + + const urlRanges = ranges.filter((token) => token.type === 'url'); + for (const mention of parseMentions(content, currentDomain)) { + const overlapsUrl = urlRanges.some((url) => mention.start < url.end && mention.end > url.start); + if (!overlapsUrl) { + ranges.push({ type: 'mention', value: mention.raw, ...mention }); + } + } + + ranges.sort((left, right) => left.start - right.start || right.end - left.end); + const tokens: RichTextToken[] = []; + let cursor = 0; + for (const range of ranges) { + if (range.start < cursor) continue; + if (range.start > cursor) { + tokens.push({ type: 'text', value: content.slice(cursor, range.start), start: cursor, end: range.start }); + } + tokens.push(range); + cursor = range.end; + } + if (cursor < content.length) { + tokens.push({ type: 'text', value: content.slice(cursor), start: cursor, end: content.length }); + } + return tokens; +} diff --git a/src/lib/notifications/browser.test.ts b/src/lib/notifications/browser.test.ts index 079ede9..1fe78cc 100644 --- a/src/lib/notifications/browser.test.ts +++ b/src/lib/notifications/browser.test.ts @@ -52,4 +52,18 @@ describe('browser notification presentation', () => { url: '/notifications', }); }); + + it('links federated mentions to the source swarm post', () => { + expect(getBrowserNotificationContent({ + id: 'notification-remote', + type: 'mention', + actor: { handle: 'alice@remote.example', displayName: 'Alice' }, + post: { + id: 'swarm:remote.example:550e8400-e29b-41d4-a716-446655440000', + content: 'Hello @bob@local.example', + }, + })).toMatchObject({ + url: '/posts/swarm:remote.example:550e8400-e29b-41d4-a716-446655440000', + }); + }); }); diff --git a/src/lib/swarm/interactions.ts b/src/lib/swarm/interactions.ts index c05833d..5b69773 100644 --- a/src/lib/swarm/interactions.ts +++ b/src/lib/swarm/interactions.ts @@ -18,6 +18,7 @@ import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node import { getPublicSwarmDomain } from './node-domain'; import { safeFederationRequest } from './safe-federation-http'; import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview'; +import { parseMentions, uniqueMentions } from '@/lib/mentions/parser'; // ============================================ // TYPES @@ -53,6 +54,8 @@ export interface SwarmInteractionResponse { success: boolean; message?: string; error?: string; + statusCode?: number; + retryable?: boolean; } export interface SwarmLikePayload { @@ -130,6 +133,8 @@ export interface SwarmMentionPayload { actorDisplayName: string; actorAvatarUrl?: string; actorNodeDomain: string; + actorDid?: string; + actorPublicKey?: string; postId: string; postContent: string; interactionId: string; @@ -273,6 +278,8 @@ async function deliverSwarmInteraction( if (await isNodeBlocked(normalizedTargetDomain)) { return { success: false, + statusCode: 403, + retryable: false, error: `Blocked node: ${normalizedTargetDomain}`, }; } @@ -295,22 +302,27 @@ async function deliverSwarmInteraction( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify(signedPayload), - signal: controller.signal, - }); - - clearTimeout(timeout); + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(signedPayload), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } if (!response.ok) { const errorText = await response.text().catch(() => 'Unknown error'); return { success: false, + statusCode: response.status, + retryable: response.status === 408 || response.status === 429 || response.status >= 500, error: `HTTP ${response.status}: ${errorText}`, }; } @@ -323,6 +335,7 @@ async function deliverSwarmInteraction( } catch (error) { return { success: false, + retryable: true, error: error instanceof Error ? error.message : 'Unknown error', }; } @@ -620,19 +633,7 @@ export async function fetchSwarmPost( * Returns array of { handle, domain } for remote mentions */ export function extractMentions(content: string): { handle: string; domain: string | null }[] { - // Match @handle or @handle@domain patterns - const mentionRegex = /@([a-zA-Z0-9_]+)(?:@([a-zA-Z0-9.-]+))?/g; - const mentions: { handle: string; domain: string | null }[] = []; - - let match; - while ((match = mentionRegex.exec(content)) !== null) { - mentions.push({ - handle: match[1].toLowerCase(), - domain: match[2]?.toLowerCase() || null, - }); - } - - return mentions; + return parseMentions(content).map(({ handle, domain }) => ({ handle, domain })); } /** @@ -648,13 +649,13 @@ export async function deliverSwarmMentions( nodeDomain: string; } ): Promise<{ delivered: number; failed: number }> { - const mentions = extractMentions(content); + const mentions = uniqueMentions(parseMentions(content, actor.nodeDomain)); let delivered = 0; let failed = 0; for (const mention of mentions) { - // Skip local mentions (no domain) - if (!mention.domain) continue; + // Local and same-node-qualified mentions are handled synchronously. + if (mention.isLocal || !mention.domain) continue; // Check if it's a swarm node const isSwarm = await isSwarmNode(mention.domain); diff --git a/src/lib/utils/federation.ts b/src/lib/utils/federation.ts index 0bb0916..01baab2 100644 --- a/src/lib/utils/federation.ts +++ b/src/lib/utils/federation.ts @@ -1,16 +1,16 @@ import { z } from 'zod'; -const localHandlePattern = /^[a-zA-Z0-9_]{3,20}$/; +const localHandlePattern = /^[a-zA-Z0-9_]{3,30}$/; const hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'; const nodeDomainPattern = `(?:localhost|127\\.0\\.0\\.1|${hostnameLabel}(?:\\.${hostnameLabel})+)(?::\\d{1,5})?`; -const federatedHandlePattern = new RegExp(`^[a-zA-Z0-9_]{3,20}(?:@${nodeDomainPattern})?$`); +const federatedHandlePattern = new RegExp(`^[a-zA-Z0-9_]{3,30}(?:@${nodeDomainPattern})?$`); const nodeDomainRegex = new RegExp(`^${nodeDomainPattern}$`); export const localHandleSchema = z .string() .min(3) - .max(20) - .regex(localHandlePattern, 'Handle must be 3-20 characters, alphanumeric and underscores only'); + .max(30) + .regex(localHandlePattern, 'Handle must be 3-30 characters, alphanumeric and underscores only'); export const federatedHandleSchema = z .string()