From 289dcc375c111c8d71bd04ef31f184a0e6a3f6f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:01:33 +0000 Subject: [PATCH] fix(db): add migration to create shared_links table for databases that skipped 025 Migration 025_add_shared_links was inserted into the Alembic chain (between 024_add_api_tokens and 025_add_user_notifications) after some databases had already been migrated past that point. Those databases never had the shared_links table created, causing OperationalError when the expire-shared-links scheduled task runs or when users try to create shared links. This commit: - Adds migration 027_ensure_shared_links_table that idempotently creates the table if it doesn't exist - Updates migrations/env.py to import all models for autogenerate support - Adds shared_links to db_migrate.py _TABLE_ORDER for proper migration ordering - Adds a regression test verifying the fix Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/utils/db_migrate.py | 1 + migrations/env.py | 15 ++++ .../versions/027_ensure_shared_links_table.py | 62 +++++++++++++++ tests/test_database.py | 79 +++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 migrations/versions/027_ensure_shared_links_table.py diff --git a/app/utils/db_migrate.py b/app/utils/db_migrate.py index f95d97f0..69461de7 100644 --- a/app/utils/db_migrate.py +++ b/app/utils/db_migrate.py @@ -34,6 +34,7 @@ _TABLE_ORDER = [ "settings_audit_log", "saved_searches", "webhook_configs", + "shared_links", ] diff --git a/migrations/env.py b/migrations/env.py index 903382a5..69ff2995 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -20,13 +20,28 @@ from app.database import Base # Ensure all models are imported so Base.metadata is populated. from app.models import ( # noqa: F401 + ApiToken, ApplicationSettings, + BackupRecord, DocumentMetadata, FileProcessingStep, FileRecord, + InAppNotification, + LocalUser, + Pipeline, + PipelineStep, ProcessingLog, SavedSearch, + ScheduledJob, SettingsAuditLog, + SharedLink, + SubscriptionPlan, + UserImapAccount, + UserIntegration, + UserNotificationPreference, + UserNotificationTarget, + UserProfile, + WebhookConfig, ) # Alembic Config object – provides access to values in alembic.ini. diff --git a/migrations/versions/027_ensure_shared_links_table.py b/migrations/versions/027_ensure_shared_links_table.py new file mode 100644 index 00000000..2a333582 --- /dev/null +++ b/migrations/versions/027_ensure_shared_links_table.py @@ -0,0 +1,62 @@ +"""Ensure shared_links table exists for databases that skipped migration 025. + +Databases that were already at revision 025_add_user_notifications or +026_add_scheduled_jobs before 025_add_shared_links was inserted into the +migration chain will never have had the ``shared_links`` table created. +This migration creates the table idempotently so those databases are +repaired on the next ``alembic upgrade head``. + +Revision ID: 027_ensure_shared_links_table +Revises: 026_add_scheduled_jobs +Create Date: 2026-03-09 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "027_ensure_shared_links_table" +down_revision: Union[str, None] = "026_add_scheduled_jobs" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Create shared_links table if it does not already exist.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + if "shared_links" not in inspector.get_table_names(): + op.create_table( + "shared_links", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("token", sa.String(64), nullable=False), + sa.Column("file_id", sa.Integer(), nullable=False), + sa.Column("owner_id", sa.String(), nullable=False), + sa.Column("label", sa.String(255), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("max_views", sa.Integer(), nullable=True), + sa.Column("view_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("password_hash", sa.String(128), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["file_id"], ["files.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token"), + ) + op.create_index("ix_shared_links_id", "shared_links", ["id"]) + op.create_index("ix_shared_links_token", "shared_links", ["token"]) + op.create_index("ix_shared_links_file_id", "shared_links", ["file_id"]) + op.create_index("ix_shared_links_owner_id", "shared_links", ["owner_id"]) + + +def downgrade() -> None: + """Drop shared_links table only if this migration created it.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + if "shared_links" in inspector.get_table_names(): + op.drop_index("ix_shared_links_owner_id", "shared_links") + op.drop_index("ix_shared_links_file_id", "shared_links") + op.drop_index("ix_shared_links_token", "shared_links") + op.drop_index("ix_shared_links_id", "shared_links") + op.drop_table("shared_links") diff --git a/tests/test_database.py b/tests/test_database.py index a49dd0a5..14e803c0 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -124,6 +124,85 @@ class TestInitDb: test_engine.dispose() + def test_init_db_creates_shared_links_for_database_missing_table(self, tmp_path): + """Regression test: databases at revision 026 that skipped 025_add_shared_links. + + Migration 025_add_shared_links was inserted into the chain between + 024_add_api_tokens and 025_add_user_notifications after some databases + had already been migrated past that point. Migration 027 creates the + table idempotently so those databases are repaired. + """ + from sqlalchemy import create_engine, text + from sqlalchemy import inspect as sa_inspect + + db_path = str(tmp_path / "regression_shared_links.db") + test_engine = create_engine(f"sqlite:///{db_path}") + + # Set up a database at revision 026 but WITHOUT the shared_links table. + # This simulates a DB that was migrated before 025_add_shared_links + # was inserted into the chain. + with test_engine.begin() as conn: + conn.execute( + text( + "CREATE TABLE files (" + "id INTEGER PRIMARY KEY, filehash VARCHAR NOT NULL, " + "original_filename VARCHAR, local_filename VARCHAR NOT NULL, " + "original_file_path VARCHAR, processed_file_path VARCHAR, " + "file_size INTEGER NOT NULL, mime_type VARCHAR, " + "is_duplicate BOOLEAN DEFAULT 0 NOT NULL, duplicate_of_id INTEGER, " + "ocr_text TEXT, ai_metadata TEXT, document_title VARCHAR, " + "ocr_quality_score INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)" + ) + ) + conn.execute( + text( + "CREATE TABLE processing_logs (" + "id INTEGER PRIMARY KEY, file_id INTEGER, task_id VARCHAR, " + "step_name VARCHAR, status VARCHAR, message VARCHAR, detail TEXT, " + "timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)" + ) + ) + conn.execute( + text( + "CREATE TABLE file_processing_steps (" + "id INTEGER PRIMARY KEY, file_id INTEGER NOT NULL, " + "step_name VARCHAR NOT NULL, status VARCHAR NOT NULL, " + "started_at DATETIME, completed_at DATETIME, error_message TEXT, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)" + ) + ) + conn.execute( + text( + "CREATE TABLE saved_searches (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, user_id VARCHAR NOT NULL, " + "name VARCHAR NOT NULL, filters TEXT NOT NULL, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "UNIQUE (user_id, name))" + ) + ) + conn.execute(text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)")) + conn.execute(text("INSERT INTO alembic_version VALUES ('026_add_scheduled_jobs')")) + + with patch("app.database.engine", test_engine), patch("app.database.DB_URL", f"sqlite:///{db_path}"): + init_db() + + inspector = sa_inspect(test_engine) + table_names = inspector.get_table_names() + assert "shared_links" in table_names + + # Verify the shared_links table has the expected columns. + columns = {col["name"] for col in inspector.get_columns("shared_links")} + assert "id" in columns + assert "token" in columns + assert "file_id" in columns + assert "owner_id" in columns + assert "expires_at" in columns + assert "is_active" in columns + + test_engine.dispose() + @pytest.mark.unit class TestGetDb: