diff --git a/surfsense_backend/alembic/versions/75_add_thread_id_to_chat_comments.py b/surfsense_backend/alembic/versions/75_add_thread_id_to_chat_comments.py new file mode 100644 index 000000000..ceeb589f4 --- /dev/null +++ b/surfsense_backend/alembic/versions/75_add_thread_id_to_chat_comments.py @@ -0,0 +1,68 @@ +"""Add thread_id to chat_comments for denormalized Electric subscriptions + +This denormalization allows a single Electric SQL subscription per thread +instead of one per message, significantly reducing connection overhead. + +Revision ID: 75 +Revises: 74 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "75" +down_revision: str | None = "74" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add thread_id column to chat_comments and backfill from messages.""" + # Add the column (nullable initially for backfill) + op.execute( + """ + ALTER TABLE chat_comments + ADD COLUMN IF NOT EXISTS thread_id INTEGER; + """ + ) + + # Backfill thread_id from the related message + op.execute( + """ + UPDATE chat_comments c + SET thread_id = m.thread_id + FROM new_chat_messages m + WHERE c.message_id = m.id + AND c.thread_id IS NULL; + """ + ) + + # Make it NOT NULL after backfill + op.execute( + """ + ALTER TABLE chat_comments + ALTER COLUMN thread_id SET NOT NULL; + """ + ) + + # Add FK constraint + op.execute( + """ + ALTER TABLE chat_comments + ADD CONSTRAINT fk_chat_comments_thread_id + FOREIGN KEY (thread_id) REFERENCES new_chat_threads(id) ON DELETE CASCADE; + """ + ) + + # Add index for efficient Electric subscriptions by thread + op.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_comments_thread_id ON chat_comments(thread_id)" + ) + + +def downgrade() -> None: + """Remove thread_id column from chat_comments.""" + op.execute("DROP INDEX IF EXISTS idx_chat_comments_thread_id") + op.execute("ALTER TABLE chat_comments DROP CONSTRAINT IF EXISTS fk_chat_comments_thread_id") + op.execute("ALTER TABLE chat_comments DROP COLUMN IF EXISTS thread_id") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 35b512c5e..181f78101 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -413,6 +413,13 @@ class ChatComment(BaseModel, TimestampMixin): nullable=False, index=True, ) + # Denormalized thread_id for efficient Electric SQL subscriptions (one per thread) + thread_id = Column( + Integer, + ForeignKey("new_chat_threads.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) parent_id = Column( Integer, ForeignKey("chat_comments.id", ondelete="CASCADE"), @@ -436,6 +443,7 @@ class ChatComment(BaseModel, TimestampMixin): # Relationships message = relationship("NewChatMessage", back_populates="comments") + thread = relationship("NewChatThread") author = relationship("User") parent = relationship( "ChatComment", remote_side="ChatComment.id", backref="replies" diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py index fa26bf6d5..7e07a56e6 100644 --- a/surfsense_backend/app/services/chat_comments_service.py +++ b/surfsense_backend/app/services/chat_comments_service.py @@ -281,8 +281,10 @@ async def create_comment( detail="You don't have permission to create comments in this search space", ) + thread = message.thread comment = ChatComment( message_id=message_id, + thread_id=thread.id, # Denormalized for efficient Electric subscriptions author_id=user.id, content=content, ) @@ -299,7 +301,6 @@ async def create_comment( user_names = await get_user_names_for_mentions(session, set(mentions_map.keys())) # Create notifications for mentioned users (excluding author) - thread = message.thread author_name = user.display_name or user.email content_preview = render_mentions(content, user_names) for mentioned_user_id, mention_id in mentions_map.items(): @@ -391,8 +392,10 @@ async def create_reply( detail="You don't have permission to create comments in this search space", ) + thread = parent_comment.message.thread reply = ChatComment( message_id=parent_comment.message_id, + thread_id=thread.id, # Denormalized for efficient Electric subscriptions parent_id=comment_id, author_id=user.id, content=content, @@ -410,7 +413,6 @@ async def create_reply( user_names = await get_user_names_for_mentions(session, set(mentions_map.keys())) # Create notifications for mentioned users (excluding author) - thread = parent_comment.message.thread author_name = user.display_name or user.email content_preview = render_mentions(content, user_names) for mentioned_user_id, mention_id in mentions_map.items():