diff --git a/apps/api/alembic/versions/0013_add_config_profiles.py b/apps/api/alembic/versions/0013_add_config_profiles.py index d042419..bf0262d 100644 --- a/apps/api/alembic/versions/0013_add_config_profiles.py +++ b/apps/api/alembic/versions/0013_add_config_profiles.py @@ -5,7 +5,7 @@ Revises: 0012_default_port_req Create Date: 2026-05-24 12:00:00.000000 """ -from typing import Sequence, Union +from collections.abc import Sequence from alembic import op import sqlalchemy as sa @@ -13,81 +13,181 @@ from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = "0013_add_config_profiles" -down_revision: Union[str, None] = "0012_default_port_req" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | None = "0012_default_port_req" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _table_exists(table_name: str) -> bool: + return sa.inspect(op.get_bind()).has_table(table_name) + + +def _column_exists(table_name: str, column_name: str) -> bool: + if not _table_exists(table_name): + return False + return column_name in { + column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name) + } + + +def _index_exists(table_name: str, index_name: str) -> bool: + if not _table_exists(table_name): + return False + return index_name in { + index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name) + } + + +def _foreign_key_exists( + table_name: str, + constrained_columns: list[str], + referred_table: str, +) -> bool: + if not _table_exists(table_name): + return False + for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name): + if ( + foreign_key.get("constrained_columns") == constrained_columns + and foreign_key.get("referred_table") == referred_table + ): + return True + return False def upgrade() -> None: - # Create config_profiles table - op.create_table( - "config_profiles", - sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("name", sa.String(length=255), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), - sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"), - ) - op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"]) + # Earlier branches may already have created config_profiles. Keep this + # migration defensive so databases can converge onto the current graph. + if not _table_exists("config_profiles"): + op.create_table( + "config_profiles", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("NOW()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("NOW()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", "name", name="uq_config_profiles_user_name" + ), + ) + if not _index_exists("config_profiles", "idx_config_profiles_user"): + op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"]) - # Create config_includes table - op.create_table( - "config_includes", - sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), - sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"), - ) - op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"]) - op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"]) + if not _table_exists("config_includes"): + op.create_table( + "config_includes", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "included_profile_id", postgresql.UUID(as_uuid=True), nullable=False + ), + sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("NOW()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("NOW()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["profile_id"], ["config_profiles.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["included_profile_id"], + ["config_profiles.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "profile_id", "included_profile_id", name="uq_config_includes_pair" + ), + ) + if not _index_exists("config_includes", "idx_config_includes_profile"): + op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"]) + if not _index_exists("config_includes", "idx_config_includes_included"): + op.create_index( + "idx_config_includes_included", "config_includes", ["included_profile_id"] + ) - # Create config_mounts table - op.create_table( - "config_mounts", - sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("mount_path", sa.String(length=1024), nullable=False), - sa.Column("content", sa.Text(), nullable=True), - sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True), - sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), - sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"]) + if not _table_exists("config_mounts"): + op.create_table( + "config_mounts", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("mount_path", sa.String(length=1024), nullable=False), + sa.Column("content", sa.Text(), nullable=True), + sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("NOW()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("NOW()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["profile_id"], ["config_profiles.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id"), + ) + if not _index_exists("config_mounts", "idx_config_mounts_profile"): + op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"]) - # Add selected_profile_id to tool_instances - op.add_column( - "tool_instances", - sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True), - ) - op.create_foreign_key( - "fk_tool_instances_selected_profile", - "tool_instances", - "config_profiles", - ["selected_profile_id"], - ["id"], - ondelete="SET NULL", - ) - op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["selected_profile_id"]) + if not _column_exists("tool_instances", "selected_profile_id"): + op.add_column( + "tool_instances", + sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + if not _foreign_key_exists( + "tool_instances", ["selected_profile_id"], "config_profiles" + ): + op.create_foreign_key( + "fk_tool_instances_selected_profile", + "tool_instances", + "config_profiles", + ["selected_profile_id"], + ["id"], + ondelete="SET NULL", + ) + if not _index_exists("tool_instances", "idx_tool_instances_selected_profile"): + op.create_index( + "idx_tool_instances_selected_profile", + "tool_instances", + ["selected_profile_id"], + ) def downgrade() -> None: # Remove selected_profile_id from tool_instances op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances") - op.drop_constraint("fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey") + op.drop_constraint( + "fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey" + ) op.drop_column("tool_instances", "selected_profile_id") # Drop config_mounts diff --git a/apps/api/alembic/versions/0014_add_profile_resolver_fields.py b/apps/api/alembic/versions/0014_add_profile_resolver_fields.py index 8d923fb..5302c5e 100644 --- a/apps/api/alembic/versions/0014_add_profile_resolver_fields.py +++ b/apps/api/alembic/versions/0014_add_profile_resolver_fields.py @@ -5,7 +5,7 @@ Revises: 0013_add_config_profiles Create Date: 2026-05-24 14:00:00.000000 """ -from typing import Sequence, Union +from collections.abc import Sequence from alembic import op import sqlalchemy as sa @@ -13,82 +13,141 @@ from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = "0014_add_profile_resolver_fields" -down_revision: Union[str, None] = "0013_add_config_profiles" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | None = "0013_add_config_profiles" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _table_exists(table_name: str) -> bool: + return sa.inspect(op.get_bind()).has_table(table_name) + + +def _column_exists(table_name: str, column_name: str) -> bool: + if not _table_exists(table_name): + return False + return column_name in { + column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name) + } + + +def _index_exists(table_name: str, index_name: str) -> bool: + if not _table_exists(table_name): + return False + return index_name in { + index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name) + } + + +def _foreign_key_exists( + table_name: str, + constrained_columns: list[str], + referred_table: str, +) -> bool: + if not _table_exists(table_name): + return False + for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name): + if ( + foreign_key.get("constrained_columns") == constrained_columns + and foreign_key.get("referred_table") == referred_table + ): + return True + return False + + +def _foreign_key_names_for_column(table_name: str, column_name: str) -> list[str]: + if not _table_exists(table_name): + return [] + names: list[str] = [] + for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name): + if column_name in foreign_key.get("constrained_columns", []): + name = foreign_key.get("name") + if name: + names.append(name) + return names def upgrade() -> None: - # Add fields to config_profiles - op.add_column( - "config_profiles", - sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True), - ) - op.add_column( - "config_profiles", - sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True), - ) - op.add_column( - "config_profiles", - sa.Column("environment_variables", sa.JSON(), nullable=True), - ) - op.add_column( - "config_profiles", - sa.Column("start_command", sa.Text(), nullable=True), - ) - op.add_column( - "config_profiles", - sa.Column("working_directory", sa.Text(), nullable=True), - ) - op.add_column( - "config_profiles", - sa.Column("port", sa.Integer(), nullable=True), - ) - op.add_column( - "config_profiles", - sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"), - ) - - # Add foreign keys for project and tool_type - op.create_foreign_key( - "fk_config_profiles_project", - "config_profiles", - "projects", - ["project_id"], - ["id"], - ondelete="CASCADE", - ) - op.create_foreign_key( - "fk_config_profiles_tool_type", - "config_profiles", - "tool_types", - ["tool_type_id"], - ["id"], - ondelete="CASCADE", - ) - - # Create indices - op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"]) - op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"]) - - # Alter config_mounts: rename mount_path to target_path, add mode, change content to files JSON - op.alter_column("config_mounts", "mount_path", new_column_name="target_path") - op.add_column( - "config_mounts", - sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"), - ) - op.add_column( - "config_mounts", - sa.Column("files", sa.JSON(), nullable=True), - ) - # Drop the source_profile foreign key if it exists - op.drop_constraint( - "config_mounts_source_profile_id_fkey", - "config_mounts", - type_="foreignkey", - ) - op.drop_column("config_mounts", "content") - op.drop_column("config_mounts", "source_profile_id") + if not _column_exists("config_profiles", "project_id"): + op.add_column( + "config_profiles", + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + if not _column_exists("config_profiles", "tool_type_id"): + op.add_column( + "config_profiles", + sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + if not _column_exists("config_profiles", "environment_variables"): + op.add_column( + "config_profiles", + sa.Column("environment_variables", sa.JSON(), nullable=True), + ) + if not _column_exists("config_profiles", "start_command"): + op.add_column( + "config_profiles", + sa.Column("start_command", sa.Text(), nullable=True), + ) + if not _column_exists("config_profiles", "working_directory"): + op.add_column( + "config_profiles", + sa.Column("working_directory", sa.Text(), nullable=True), + ) + if not _column_exists("config_profiles", "port"): + op.add_column("config_profiles", sa.Column("port", sa.Integer(), nullable=True)) + if not _column_exists("config_profiles", "is_default"): + op.add_column( + "config_profiles", + sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"), + ) + + if not _foreign_key_exists("config_profiles", ["project_id"], "projects"): + op.create_foreign_key( + "fk_config_profiles_project", + "config_profiles", + "projects", + ["project_id"], + ["id"], + ondelete="CASCADE", + ) + if not _foreign_key_exists("config_profiles", ["tool_type_id"], "tool_types"): + op.create_foreign_key( + "fk_config_profiles_tool_type", + "config_profiles", + "tool_types", + ["tool_type_id"], + ["id"], + ondelete="CASCADE", + ) + + if not _index_exists("config_profiles", "idx_config_profiles_project"): + op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"]) + if not _index_exists("config_profiles", "idx_config_profiles_tool_type"): + op.create_index( + "idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"] + ) + + if _column_exists("config_mounts", "mount_path") and not _column_exists( + "config_mounts", "target_path" + ): + op.alter_column("config_mounts", "mount_path", new_column_name="target_path") + if not _column_exists("config_mounts", "mode"): + op.add_column( + "config_mounts", + sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"), + ) + if not _column_exists("config_mounts", "files"): + op.add_column( + "config_mounts", + sa.Column("files", sa.JSON(), nullable=True), + ) + for constraint_name in _foreign_key_names_for_column( + "config_mounts", "source_profile_id" + ): + op.drop_constraint(constraint_name, "config_mounts", type_="foreignkey") + if _column_exists("config_mounts", "content"): + op.drop_column("config_mounts", "content") + if _column_exists("config_mounts", "source_profile_id"): + op.drop_column("config_mounts", "source_profile_id") def downgrade() -> None: @@ -104,11 +163,13 @@ def downgrade() -> None: op.drop_column("config_mounts", "files") op.drop_column("config_mounts", "mode") op.alter_column("config_mounts", "target_path", new_column_name="mount_path") - + # Restore config_profiles op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles") op.drop_index("idx_config_profiles_project", table_name="config_profiles") - op.drop_constraint("fk_config_profiles_tool_type", "config_profiles", type_="foreignkey") + op.drop_constraint( + "fk_config_profiles_tool_type", "config_profiles", type_="foreignkey" + ) op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey") op.drop_column("config_profiles", "is_default") op.drop_column("config_profiles", "port")