From 8e666b4704203cfc058a247361a4045ea79b6e6e Mon Sep 17 00:00:00 2001 From: -LAN- Date: Tue, 2 Sep 2025 23:57:52 +0800 Subject: [PATCH 01/13] refactor: remove unused is_deleted field from conversations table Remove the is_deleted field that was never utilized for soft deletion. This simplifies queries and reduces unnecessary database overhead. Fixes #25017 --- api/controllers/console/app/conversation.py | 6 ++-- ...09_remove_unused_is_deleted_field_from_.py | 33 +++++++++++++++++++ api/models/model.py | 2 -- api/services/conversation_service.py | 2 -- .../services/test_web_conversation_service.py | 1 - 5 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index c16dcfd91f..2819e5a054 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -338,9 +338,7 @@ class CompletionConversationApi(Resource): current_user, _ = current_account_with_tenant() args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore - query = sa.select(Conversation).where( - Conversation.app_id == app_model.id, Conversation.mode == "completion", Conversation.is_deleted.is_(False) - ) + query = sa.select(Conversation).where(Conversation.app_id == app_model.id, Conversation.mode == "completion") if args.keyword: query = query.join(Message, Message.conversation_id == Conversation.id).where( @@ -452,7 +450,7 @@ class ChatConversationApi(Resource): .subquery() ) - query = sa.select(Conversation).where(Conversation.app_id == app_model.id, Conversation.is_deleted.is_(False)) + query = sa.select(Conversation).where(Conversation.app_id == app_model.id) if args.keyword: keyword_filter = f"%{args.keyword}%" diff --git a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py new file mode 100644 index 0000000000..8db951133c --- /dev/null +++ b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py @@ -0,0 +1,33 @@ +"""remove unused is_deleted field from conversations + +Revision ID: 4f02b6704509 +Revises: b95962a3885c +Create Date: 2025-09-02 20:12:37.311318 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4f02b6704509' +down_revision = 'b95962a3885c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('conversations', schema=None) as batch_op: + batch_op.drop_column('is_deleted') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('conversations', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_deleted', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False)) + + # ### end Alembic commands ### diff --git a/api/models/model.py b/api/models/model.py index 88cb945b3f..d2ae4aabe9 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -676,8 +676,6 @@ class Conversation(Base): "MessageAnnotation", backref="conversation", lazy="select", passive_deletes="all" ) - is_deleted: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) - @property def inputs(self) -> dict[str, Any]: inputs = self._inputs.copy() diff --git a/api/services/conversation_service.py b/api/services/conversation_service.py index 5253199552..10a68f187f 100644 --- a/api/services/conversation_service.py +++ b/api/services/conversation_service.py @@ -47,7 +47,6 @@ class ConversationService: return InfiniteScrollPagination(data=[], limit=limit, has_more=False) stmt = select(Conversation).where( - Conversation.is_deleted == False, Conversation.app_id == app_model.id, Conversation.from_source == ("api" if isinstance(user, EndUser) else "console"), Conversation.from_end_user_id == (user.id if isinstance(user, EndUser) else None), @@ -166,7 +165,6 @@ class ConversationService: Conversation.from_source == ("api" if isinstance(user, EndUser) else "console"), Conversation.from_end_user_id == (user.id if isinstance(user, EndUser) else None), Conversation.from_account_id == (user.id if isinstance(user, Account) else None), - Conversation.is_deleted == False, ) .first() ) diff --git a/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py b/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py index bbbf48ede9..6207761f56 100644 --- a/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py @@ -149,7 +149,6 @@ class TestWebConversationService: from_end_user_id=user.id if isinstance(user, EndUser) else None, from_account_id=user.id if isinstance(user, Account) else None, dialogue_count=0, - is_deleted=False, ) from extensions.ext_database import db From b8885fa02971463681a89ae306463dead468bf58 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Wed, 3 Sep 2025 10:15:34 +0800 Subject: [PATCH 02/13] fix: delete soft-deleted conversations before dropping is_deleted column Ensure any existing soft-deleted conversations are removed from the database before dropping the is_deleted column in the migration. --- ...2_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py index 8db951133c..6da8dfae6c 100644 --- a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py +++ b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py @@ -19,6 +19,9 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### + # Delete any conversations where is_deleted is true before dropping the column + op.execute("DELETE FROM conversations WHERE is_deleted = true") + with op.batch_alter_table('conversations', schema=None) as batch_op: batch_op.drop_column('is_deleted') From d3490ebb0ff865dd4c185ef80adcf074b3f61d0e Mon Sep 17 00:00:00 2001 From: -LAN- Date: Wed, 3 Sep 2025 10:18:59 +0800 Subject: [PATCH 03/13] refactor: backup soft-deleted conversations instead of deleting them - Create backup table conversations_4f02b6704509_bak for soft-deleted records - Only create backup table if soft-deleted records exist - Support recovery during downgrade migration - Drop backup table after successful restoration --- ...09_remove_unused_is_deleted_field_from_.py | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py index 6da8dfae6c..d0fd8c9125 100644 --- a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py +++ b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py @@ -19,7 +19,21 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - # Delete any conversations where is_deleted is true before dropping the column + # Create backup table for soft-deleted conversations + backup_table_name = 'conversations_4f02b6704509_bak' + + # Check if there are any soft-deleted conversations + result = op.get_bind().execute(sa.text("SELECT COUNT(*) FROM conversations WHERE is_deleted = true")) + count = result.scalar() + + if count > 0: + # Create backup table with all columns from conversations + op.execute(sa.text(f""" + CREATE TABLE {backup_table_name} AS + SELECT * FROM conversations WHERE is_deleted = true + """)) + + # Delete soft-deleted conversations from main table op.execute("DELETE FROM conversations WHERE is_deleted = true") with op.batch_alter_table('conversations', schema=None) as batch_op: @@ -32,5 +46,26 @@ def downgrade(): # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table('conversations', schema=None) as batch_op: batch_op.add_column(sa.Column('is_deleted', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False)) + + # Restore soft-deleted conversations from backup table if it exists + backup_table_name = 'conversations_4f02b6704509_bak' + + # Check if backup table exists + result = op.get_bind().execute(sa.text(f""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = '{backup_table_name}' + ) + """)) + + if result.scalar(): + # Restore the soft-deleted conversations + op.execute(sa.text(f""" + INSERT INTO conversations + SELECT * FROM {backup_table_name} + """)) + + # Drop the backup table after restoration + op.execute(sa.text(f"DROP TABLE {backup_table_name}")) # ### end Alembic commands ### From 4e8e53c57909bff154f053c6b2c4ae1d9c45cd12 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Wed, 3 Sep 2025 10:21:21 +0800 Subject: [PATCH 04/13] refactor: extract backup table name as module constant Share backup_table_name between upgrade() and downgrade() functions to avoid duplication and ensure consistency. --- ...2012-4f02b6704509_remove_unused_is_deleted_field_from_.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py index d0fd8c9125..8f0ccd9c93 100644 --- a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py +++ b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py @@ -16,11 +16,13 @@ down_revision = 'b95962a3885c' branch_labels = None depends_on = None +# Backup table name for soft-deleted conversations +backup_table_name = 'conversations_4f02b6704509_bak' + def upgrade(): # ### commands auto generated by Alembic - please adjust! ### # Create backup table for soft-deleted conversations - backup_table_name = 'conversations_4f02b6704509_bak' # Check if there are any soft-deleted conversations result = op.get_bind().execute(sa.text("SELECT COUNT(*) FROM conversations WHERE is_deleted = true")) @@ -48,7 +50,6 @@ def downgrade(): batch_op.add_column(sa.Column('is_deleted', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False)) # Restore soft-deleted conversations from backup table if it exists - backup_table_name = 'conversations_4f02b6704509_bak' # Check if backup table exists result = op.get_bind().execute(sa.text(f""" From 62f2288460fb2d6bf07be76df6edff7900b93e2e Mon Sep 17 00:00:00 2001 From: -LAN- Date: Wed, 3 Sep 2025 10:22:20 +0800 Subject: [PATCH 05/13] refactor: use SQLAlchemy inspector for database-agnostic table existence check Replace PostgreSQL-specific information_schema query with SQLAlchemy's inspector API that works across all supported database types. --- ...6704509_remove_unused_is_deleted_field_from_.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py index 8f0ccd9c93..ae90911817 100644 --- a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py +++ b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py @@ -8,6 +8,7 @@ Create Date: 2025-09-02 20:12:37.311318 from alembic import op import models as models import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. @@ -51,15 +52,12 @@ def downgrade(): # Restore soft-deleted conversations from backup table if it exists - # Check if backup table exists - result = op.get_bind().execute(sa.text(f""" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = '{backup_table_name}' - ) - """)) + # Check if backup table exists using inspector (works for all database types) + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = inspector.get_table_names() - if result.scalar(): + if backup_table_name in existing_tables: # Restore the soft-deleted conversations op.execute(sa.text(f""" INSERT INTO conversations From ab142a302e6da5f438adcb3912129dd515a02740 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 27 Nov 2025 17:12:18 +0800 Subject: [PATCH 06/13] Fix migration down_revision after merge --- ...09_remove_unused_is_deleted_field_from_.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py index ae90911817..36103de6de 100644 --- a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py +++ b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py @@ -13,7 +13,7 @@ from sqlalchemy import inspect # revision identifiers, used by Alembic. revision = '4f02b6704509' -down_revision = 'b95962a3885c' +down_revision = '7bb281b7a422' branch_labels = None depends_on = None @@ -24,21 +24,21 @@ backup_table_name = 'conversations_4f02b6704509_bak' def upgrade(): # ### commands auto generated by Alembic - please adjust! ### # Create backup table for soft-deleted conversations - + # Check if there are any soft-deleted conversations result = op.get_bind().execute(sa.text("SELECT COUNT(*) FROM conversations WHERE is_deleted = true")) count = result.scalar() - + if count > 0: # Create backup table with all columns from conversations op.execute(sa.text(f""" CREATE TABLE {backup_table_name} AS SELECT * FROM conversations WHERE is_deleted = true """)) - + # Delete soft-deleted conversations from main table op.execute("DELETE FROM conversations WHERE is_deleted = true") - + with op.batch_alter_table('conversations', schema=None) as batch_op: batch_op.drop_column('is_deleted') @@ -49,21 +49,21 @@ def downgrade(): # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table('conversations', schema=None) as batch_op: batch_op.add_column(sa.Column('is_deleted', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False)) - + # Restore soft-deleted conversations from backup table if it exists - + # Check if backup table exists using inspector (works for all database types) bind = op.get_bind() inspector = inspect(bind) existing_tables = inspector.get_table_names() - + if backup_table_name in existing_tables: # Restore the soft-deleted conversations op.execute(sa.text(f""" - INSERT INTO conversations + INSERT INTO conversations SELECT * FROM {backup_table_name} """)) - + # Drop the backup table after restoration op.execute(sa.text(f"DROP TABLE {backup_table_name}")) From b31b0446dd1977da035a30a6f5289dd920e849d8 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 27 Nov 2025 17:57:36 +0800 Subject: [PATCH 07/13] Make conversation is_deleted migration cross-database safe --- ...704509_remove_unused_is_deleted_field_from_.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py index 36103de6de..e39cdf50d4 100644 --- a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py +++ b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py @@ -1,9 +1,11 @@ """remove unused is_deleted field from conversations Revision ID: 4f02b6704509 -Revises: b95962a3885c +Revises: 7bb281b7a422 Create Date: 2025-09-02 20:12:37.311318 +This migration runs on both PostgreSQL and MySQL. Avoid hard‑coding boolean +literals so the SQL renders correctly for each dialect. """ from alembic import op import models as models @@ -24,20 +26,23 @@ backup_table_name = 'conversations_4f02b6704509_bak' def upgrade(): # ### commands auto generated by Alembic - please adjust! ### # Create backup table for soft-deleted conversations + bind = op.get_bind() + dialect = bind.dialect.name + true_literal = "1" if dialect == "mysql" else "TRUE" # Check if there are any soft-deleted conversations - result = op.get_bind().execute(sa.text("SELECT COUNT(*) FROM conversations WHERE is_deleted = true")) + result = bind.execute(sa.text(f"SELECT COUNT(*) FROM conversations WHERE is_deleted = {true_literal}")) count = result.scalar() if count > 0: # Create backup table with all columns from conversations op.execute(sa.text(f""" CREATE TABLE {backup_table_name} AS - SELECT * FROM conversations WHERE is_deleted = true + SELECT * FROM conversations WHERE is_deleted = {true_literal} """)) # Delete soft-deleted conversations from main table - op.execute("DELETE FROM conversations WHERE is_deleted = true") + op.execute(sa.text(f"DELETE FROM conversations WHERE is_deleted = {true_literal}")) with op.batch_alter_table('conversations', schema=None) as batch_op: batch_op.drop_column('is_deleted') @@ -54,6 +59,8 @@ def downgrade(): # Check if backup table exists using inspector (works for all database types) bind = op.get_bind() + dialect = bind.dialect.name + true_literal = "1" if dialect == "mysql" else "TRUE" inspector = inspect(bind) existing_tables = inspector.get_table_names() From 26c2ad3d2b8fcfdad260f3f83373588d5df6d2f5 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 27 Nov 2025 18:01:46 +0800 Subject: [PATCH 08/13] Handle offline migrations and MySQL/PostgreSQL booleans --- ...09_remove_unused_is_deleted_field_from_.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py index e39cdf50d4..55d7a1ed5e 100644 --- a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py +++ b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py @@ -26,20 +26,15 @@ backup_table_name = 'conversations_4f02b6704509_bak' def upgrade(): # ### commands auto generated by Alembic - please adjust! ### # Create backup table for soft-deleted conversations - bind = op.get_bind() - dialect = bind.dialect.name + context = op.get_context() + dialect = context.dialect.name if hasattr(context, "dialect") else op.get_bind().dialect.name true_literal = "1" if dialect == "mysql" else "TRUE" - # Check if there are any soft-deleted conversations - result = bind.execute(sa.text(f"SELECT COUNT(*) FROM conversations WHERE is_deleted = {true_literal}")) - count = result.scalar() - - if count > 0: - # Create backup table with all columns from conversations - op.execute(sa.text(f""" - CREATE TABLE {backup_table_name} AS - SELECT * FROM conversations WHERE is_deleted = {true_literal} - """)) + # Create backup table with all soft-deleted conversations (works even if zero rows) + op.execute(sa.text(f""" + CREATE TABLE {backup_table_name} AS + SELECT * FROM conversations WHERE is_deleted = {true_literal} + """)) # Delete soft-deleted conversations from main table op.execute(sa.text(f"DELETE FROM conversations WHERE is_deleted = {true_literal}")) @@ -59,6 +54,9 @@ def downgrade(): # Check if backup table exists using inspector (works for all database types) bind = op.get_bind() + if bind is None: + # Offline migration generation; skip data restoration logic + return dialect = bind.dialect.name true_literal = "1" if dialect == "mysql" else "TRUE" inspector = inspect(bind) From 53c6c27a980fdef6f8a52cf519a1cbb0a4acb23f Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 27 Nov 2025 18:04:48 +0800 Subject: [PATCH 09/13] Use SQLAlchemy expressions for is_deleted migration backup --- ...09_remove_unused_is_deleted_field_from_.py | 71 +++++++++++-------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py index 55d7a1ed5e..ce5382b1f4 100644 --- a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py +++ b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py @@ -4,8 +4,8 @@ Revision ID: 4f02b6704509 Revises: 7bb281b7a422 Create Date: 2025-09-02 20:12:37.311318 -This migration runs on both PostgreSQL and MySQL. Avoid hard‑coding boolean -literals so the SQL renders correctly for each dialect. +This migration runs on both PostgreSQL and MySQL. It uses SQLAlchemy +expressions instead of raw SQL so dialects handle boolean literals. """ from alembic import op import models as models @@ -25,19 +25,35 @@ backup_table_name = 'conversations_4f02b6704509_bak' def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - # Create backup table for soft-deleted conversations context = op.get_context() - dialect = context.dialect.name if hasattr(context, "dialect") else op.get_bind().dialect.name - true_literal = "1" if dialect == "mysql" else "TRUE" + if context.is_offline_mode(): + # In offline mode we cannot reflect columns; skip backup/data moves. + with op.batch_alter_table('conversations', schema=None) as batch_op: + batch_op.drop_column('is_deleted') + return - # Create backup table with all soft-deleted conversations (works even if zero rows) - op.execute(sa.text(f""" - CREATE TABLE {backup_table_name} AS - SELECT * FROM conversations WHERE is_deleted = {true_literal} - """)) + bind = op.get_bind() + metadata = sa.MetaData() + conversations = sa.Table('conversations', metadata, autoload_with=bind) - # Delete soft-deleted conversations from main table - op.execute(sa.text(f"DELETE FROM conversations WHERE is_deleted = {true_literal}")) + # Drop leftover backup table if it exists (idempotent reruns) + inspector = inspect(bind) + if backup_table_name in inspector.get_table_names(): + op.drop_table(backup_table_name) + + # Create backup table with identical schema + op.create_table(backup_table_name, *[col.copy() for col in conversations.columns]) + backup_table = sa.Table(backup_table_name, metadata, autoload_with=bind) + + # Copy soft-deleted rows into backup + insert_backup = sa.insert(backup_table).from_select( + conversations.columns.keys(), + sa.select(*conversations.c).where(conversations.c.is_deleted.is_(sa.true())), + ) + bind.execute(insert_backup) + + # Delete soft-deleted rows from main table + bind.execute(sa.delete(conversations).where(conversations.c.is_deleted.is_(sa.true()))) with op.batch_alter_table('conversations', schema=None) as batch_op: batch_op.drop_column('is_deleted') @@ -51,25 +67,24 @@ def downgrade(): batch_op.add_column(sa.Column('is_deleted', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False)) # Restore soft-deleted conversations from backup table if it exists - - # Check if backup table exists using inspector (works for all database types) - bind = op.get_bind() - if bind is None: - # Offline migration generation; skip data restoration logic + context = op.get_context() + if context.is_offline_mode(): return - dialect = bind.dialect.name - true_literal = "1" if dialect == "mysql" else "TRUE" + + bind = op.get_bind() + metadata = sa.MetaData() inspector = inspect(bind) - existing_tables = inspector.get_table_names() - if backup_table_name in existing_tables: - # Restore the soft-deleted conversations - op.execute(sa.text(f""" - INSERT INTO conversations - SELECT * FROM {backup_table_name} - """)) + if backup_table_name in inspector.get_table_names(): + conversations = sa.Table('conversations', metadata, autoload_with=bind) + backup_table = sa.Table(backup_table_name, metadata, autoload_with=bind) - # Drop the backup table after restoration - op.execute(sa.text(f"DROP TABLE {backup_table_name}")) + restore_stmt = sa.insert(conversations).from_select( + conversations.columns.keys(), + sa.select(*backup_table.c), + ) + bind.execute(restore_stmt) + + op.drop_table(backup_table_name) # ### end Alembic commands ### From 5efe443d852a19c93caa45bdbdec323e97f9dfbb Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 27 Nov 2025 18:07:13 +0800 Subject: [PATCH 10/13] Drop offline guards in is_deleted migration --- ...02b6704509_remove_unused_is_deleted_field_from_.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py index ce5382b1f4..4958e09a61 100644 --- a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py +++ b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py @@ -25,13 +25,6 @@ backup_table_name = 'conversations_4f02b6704509_bak' def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - context = op.get_context() - if context.is_offline_mode(): - # In offline mode we cannot reflect columns; skip backup/data moves. - with op.batch_alter_table('conversations', schema=None) as batch_op: - batch_op.drop_column('is_deleted') - return - bind = op.get_bind() metadata = sa.MetaData() conversations = sa.Table('conversations', metadata, autoload_with=bind) @@ -67,10 +60,6 @@ def downgrade(): batch_op.add_column(sa.Column('is_deleted', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False)) # Restore soft-deleted conversations from backup table if it exists - context = op.get_context() - if context.is_offline_mode(): - return - bind = op.get_bind() metadata = sa.MetaData() inspector = inspect(bind) From 4ad79538e2302978282b452468259c7206a1b18b Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 27 Nov 2025 18:20:31 +0800 Subject: [PATCH 11/13] Simplify is_deleted migration: drop column without backup --- ...09_remove_unused_is_deleted_field_from_.py | 48 +------------------ 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py index 4958e09a61..a2bda76cd6 100644 --- a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py +++ b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py @@ -4,13 +4,11 @@ Revision ID: 4f02b6704509 Revises: 7bb281b7a422 Create Date: 2025-09-02 20:12:37.311318 -This migration runs on both PostgreSQL and MySQL. It uses SQLAlchemy -expressions instead of raw SQL so dialects handle boolean literals. +This migration runs on both PostgreSQL and MySQL. """ from alembic import op import models as models import sqlalchemy as sa -from sqlalchemy import inspect # revision identifiers, used by Alembic. @@ -19,35 +17,8 @@ down_revision = '7bb281b7a422' branch_labels = None depends_on = None -# Backup table name for soft-deleted conversations -backup_table_name = 'conversations_4f02b6704509_bak' - - def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - bind = op.get_bind() - metadata = sa.MetaData() - conversations = sa.Table('conversations', metadata, autoload_with=bind) - - # Drop leftover backup table if it exists (idempotent reruns) - inspector = inspect(bind) - if backup_table_name in inspector.get_table_names(): - op.drop_table(backup_table_name) - - # Create backup table with identical schema - op.create_table(backup_table_name, *[col.copy() for col in conversations.columns]) - backup_table = sa.Table(backup_table_name, metadata, autoload_with=bind) - - # Copy soft-deleted rows into backup - insert_backup = sa.insert(backup_table).from_select( - conversations.columns.keys(), - sa.select(*conversations.c).where(conversations.c.is_deleted.is_(sa.true())), - ) - bind.execute(insert_backup) - - # Delete soft-deleted rows from main table - bind.execute(sa.delete(conversations).where(conversations.c.is_deleted.is_(sa.true()))) - with op.batch_alter_table('conversations', schema=None) as batch_op: batch_op.drop_column('is_deleted') @@ -59,21 +30,4 @@ def downgrade(): with op.batch_alter_table('conversations', schema=None) as batch_op: batch_op.add_column(sa.Column('is_deleted', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False)) - # Restore soft-deleted conversations from backup table if it exists - bind = op.get_bind() - metadata = sa.MetaData() - inspector = inspect(bind) - - if backup_table_name in inspector.get_table_names(): - conversations = sa.Table('conversations', metadata, autoload_with=bind) - backup_table = sa.Table(backup_table_name, metadata, autoload_with=bind) - - restore_stmt = sa.insert(conversations).from_select( - conversations.columns.keys(), - sa.select(*backup_table.c), - ) - bind.execute(restore_stmt) - - op.drop_table(backup_table_name) - # ### end Alembic commands ### From eadf137b75ff06bbfe56d8b115475eb58c101d32 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 27 Nov 2025 18:37:11 +0800 Subject: [PATCH 12/13] Delete soft-deleted conversations before dropping is_deleted column Use sa.table() with sa.delete() and sa.true() to generate cross-database compatible SQL that works in both online and offline modes without requiring schema reflection. --- ...09_remove_unused_is_deleted_field_from_.py | 33 ------------------- ...a95e676f_remove_unused_is_deleted_from_.py | 29 ++++++++++++++++ 2 files changed, 29 insertions(+), 33 deletions(-) delete mode 100644 api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py create mode 100644 api/migrations/versions/2025_11_27_1827-e5d7a95e676f_remove_unused_is_deleted_from_.py diff --git a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py b/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py deleted file mode 100644 index a2bda76cd6..0000000000 --- a/api/migrations/versions/2025_09_02_2012-4f02b6704509_remove_unused_is_deleted_field_from_.py +++ /dev/null @@ -1,33 +0,0 @@ -"""remove unused is_deleted field from conversations - -Revision ID: 4f02b6704509 -Revises: 7bb281b7a422 -Create Date: 2025-09-02 20:12:37.311318 - -This migration runs on both PostgreSQL and MySQL. -""" -from alembic import op -import models as models -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '4f02b6704509' -down_revision = '7bb281b7a422' -branch_labels = None -depends_on = None - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('conversations', schema=None) as batch_op: - batch_op.drop_column('is_deleted') - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('conversations', schema=None) as batch_op: - batch_op.add_column(sa.Column('is_deleted', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False)) - - # ### end Alembic commands ### diff --git a/api/migrations/versions/2025_11_27_1827-e5d7a95e676f_remove_unused_is_deleted_from_.py b/api/migrations/versions/2025_11_27_1827-e5d7a95e676f_remove_unused_is_deleted_from_.py new file mode 100644 index 0000000000..f7caf13ea2 --- /dev/null +++ b/api/migrations/versions/2025_11_27_1827-e5d7a95e676f_remove_unused_is_deleted_from_.py @@ -0,0 +1,29 @@ +"""remove unused is_deleted from conversations + +Revision ID: e5d7a95e676f +Revises: 7bb281b7a422 +Create Date: 2025-11-27 18:27:09.006691 + +""" +import sqlalchemy as sa +from alembic import op + +revision = "e5d7a95e676f" +down_revision = "7bb281b7a422" +branch_labels = None +depends_on = None + + +def upgrade(): + conversations = sa.table("conversations", sa.column("is_deleted", sa.Boolean)) + op.execute(sa.delete(conversations).where(conversations.c.is_deleted == sa.true())) + + with op.batch_alter_table("conversations", schema=None) as batch_op: + batch_op.drop_column("is_deleted") + + +def downgrade(): + with op.batch_alter_table("conversations", schema=None) as batch_op: + batch_op.add_column( + sa.Column("is_deleted", sa.BOOLEAN(), server_default=sa.text("false"), autoincrement=False, nullable=False) + ) From 4919e138e255946e2e00277f6212d91b407555bb Mon Sep 17 00:00:00 2001 From: -LAN- Date: Mon, 15 Dec 2025 19:35:54 +0800 Subject: [PATCH 13/13] Fix migration base revision --- ..._11_27_1827-e5d7a95e676f_remove_unused_is_deleted_from_.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/migrations/versions/2025_11_27_1827-e5d7a95e676f_remove_unused_is_deleted_from_.py b/api/migrations/versions/2025_11_27_1827-e5d7a95e676f_remove_unused_is_deleted_from_.py index f7caf13ea2..d6b51b36b3 100644 --- a/api/migrations/versions/2025_11_27_1827-e5d7a95e676f_remove_unused_is_deleted_from_.py +++ b/api/migrations/versions/2025_11_27_1827-e5d7a95e676f_remove_unused_is_deleted_from_.py @@ -1,7 +1,7 @@ """remove unused is_deleted from conversations Revision ID: e5d7a95e676f -Revises: 7bb281b7a422 +Revises: d57accd375ae Create Date: 2025-11-27 18:27:09.006691 """ @@ -9,7 +9,7 @@ import sqlalchemy as sa from alembic import op revision = "e5d7a95e676f" -down_revision = "7bb281b7a422" +down_revision = "d57accd375ae" branch_labels = None depends_on = None