diff --git a/CLAUDE.md b/CLAUDE.md index 0d9648ecf..e1c6da2bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -238,6 +238,12 @@ Per-user file quota enforcement tracked via the `file_count` and `file_quota` co **Migration:** `openrag/scripts/migrations/alembic/versions/c224d4befe71_add_file_count_and_file_quota.py` +### Alembic Migration Idempotency + +`Base.metadata.create_all()` runs at app startup (`PartitionFileManager.__init__` in `openrag/components/indexer/vectordb/utils.py`), so a freshly bootstrapped database already contains the full current-model schema before alembic ever touches it. Migrations must therefore be **idempotent** — re-applying an `ADD COLUMN` / `CREATE TABLE` / `CREATE INDEX` against an already-existing object would raise `DuplicateColumn` / `DuplicateTable`. + +Guard every schema-mutating op with an inspector-based existence check (`table_exists`, `column_exists`, `index_exists`, `fk_exists`), in both `upgrade()` and `downgrade()`. For migrations that convert a column type, also short-circuit if the column is already the target type. + ### Configuration Configuration uses Hydra with YAML files in `.hydra_config/`: diff --git a/openrag/scripts/migrations/alembic/env.py b/openrag/scripts/migrations/alembic/env.py index ddc940dc0..88a6c4f78 100644 --- a/openrag/scripts/migrations/alembic/env.py +++ b/openrag/scripts/migrations/alembic/env.py @@ -1,5 +1,11 @@ +import os +import sys from logging.config import fileConfig +# Make modules alongside env.py (e.g. schema_helpers) importable from +# migration scripts regardless of the cwd alembic is invoked from. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from alembic import context from components.indexer.vectordb.models import Base from config import load_config diff --git a/openrag/scripts/migrations/alembic/schema_helpers.py b/openrag/scripts/migrations/alembic/schema_helpers.py new file mode 100644 index 000000000..ac2bfebd9 --- /dev/null +++ b/openrag/scripts/migrations/alembic/schema_helpers.py @@ -0,0 +1,47 @@ +"""Shared inspection helpers for idempotent Alembic migrations. + +Needed because `Base.metadata.create_all()` at app startup may create the +current-model schema directly on fresh (or older) deployments — so migrations +must tolerate objects already existing. +""" + +from alembic import op +from sqlalchemy import inspect + + +def table_exists(table: str) -> bool: + return table in inspect(op.get_bind()).get_table_names() + + +def column_exists(table: str, column: str) -> bool: + if not table_exists(table): + return False + return any(c["name"] == column for c in inspect(op.get_bind()).get_columns(table)) + + +def index_exists(table: str, index: str) -> bool: + if not table_exists(table): + return False + return any(i["name"] == index for i in inspect(op.get_bind()).get_indexes(table)) + + +def fk_exists(table: str, fk_name: str) -> bool: + if not table_exists(table): + return False + return any(fk["name"] == fk_name for fk in inspect(op.get_bind()).get_foreign_keys(table)) + + +def unique_constraint_exists(table: str, constraint_name: str) -> bool: + if not table_exists(table): + return False + return any(uc["name"] == constraint_name for uc in inspect(op.get_bind()).get_unique_constraints(table)) + + +def column_type_is(table: str, column: str, sa_type: type) -> bool: + """Return True if `table.column` exists and its type is an instance of `sa_type`.""" + if not table_exists(table): + return False + for col in inspect(op.get_bind()).get_columns(table): + if col["name"] == column: + return isinstance(col["type"], sa_type) + return False diff --git a/openrag/scripts/migrations/alembic/versions/4add4d260575_initial_migration.py b/openrag/scripts/migrations/alembic/versions/4add4d260575_initial_migration.py index 8106e308c..a60981fe6 100644 --- a/openrag/scripts/migrations/alembic/versions/4add4d260575_initial_migration.py +++ b/openrag/scripts/migrations/alembic/versions/4add4d260575_initial_migration.py @@ -10,23 +10,7 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy import inspect - - -def table_exists(table_name: str) -> bool: - """Check if a table exists in the database.""" - bind = op.get_bind() - inspector = inspect(bind) - return table_name in inspector.get_table_names() - - -def index_exists(index_name: str, table_name: str) -> bool: - """Check if an index exists on a table.""" - bind = op.get_bind() - inspector = inspect(bind) - indexes = inspector.get_indexes(table_name) - return any(idx["name"] == index_name for idx in indexes) - +from schema_helpers import index_exists, table_exists # revision identifiers, used by Alembic. revision: str = "4add4d260575" @@ -49,14 +33,14 @@ def upgrade() -> None: # Create indexes for partitions table if they don't exist if table_exists("partitions"): - if not index_exists("ix_partitions_created_at", "partitions"): + if not index_exists("partitions", "ix_partitions_created_at"): op.create_index( op.f("ix_partitions_created_at"), "partitions", ["created_at"], unique=False, ) - if not index_exists("ix_partitions_partition", "partitions"): + if not index_exists("partitions", "ix_partitions_partition"): op.create_index( op.f("ix_partitions_partition"), "partitions", @@ -82,16 +66,16 @@ def upgrade() -> None: # Create indexes for files table if they don't exist if table_exists("files"): - if not index_exists("ix_files_file_id", "files"): + if not index_exists("files", "ix_files_file_id"): op.create_index(op.f("ix_files_file_id"), "files", ["file_id"], unique=False) - if not index_exists("ix_files_partition_name", "files"): + if not index_exists("files", "ix_files_partition_name"): op.create_index( op.f("ix_files_partition_name"), "files", ["partition_name"], unique=False, ) - if not index_exists("ix_partition_file", "files"): + if not index_exists("files", "ix_partition_file"): op.create_index( "ix_partition_file", "files", @@ -104,17 +88,17 @@ def downgrade() -> None: """Downgrade schema.""" # Drop indexes and tables if they exist if table_exists("files"): - if index_exists("ix_partition_file", "files"): + if index_exists("files", "ix_partition_file"): op.drop_index("ix_partition_file", table_name="files") - if index_exists("ix_files_partition_name", "files"): + if index_exists("files", "ix_files_partition_name"): op.drop_index(op.f("ix_files_partition_name"), table_name="files") - if index_exists("ix_files_file_id", "files"): + if index_exists("files", "ix_files_file_id"): op.drop_index(op.f("ix_files_file_id"), table_name="files") op.drop_table("files") if table_exists("partitions"): - if index_exists("ix_partitions_partition", "partitions"): + if index_exists("partitions", "ix_partitions_partition"): op.drop_index(op.f("ix_partitions_partition"), table_name="partitions") - if index_exists("ix_partitions_created_at", "partitions"): + if index_exists("partitions", "ix_partitions_created_at"): op.drop_index(op.f("ix_partitions_created_at"), table_name="partitions") op.drop_table("partitions") diff --git a/openrag/scripts/migrations/alembic/versions/a1b2c3d4e5f6_add_document_relationships.py b/openrag/scripts/migrations/alembic/versions/a1b2c3d4e5f6_add_document_relationships.py index 171199b29..0867bc1c1 100644 --- a/openrag/scripts/migrations/alembic/versions/a1b2c3d4e5f6_add_document_relationships.py +++ b/openrag/scripts/migrations/alembic/versions/a1b2c3d4e5f6_add_document_relationships.py @@ -15,24 +15,7 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy import inspect - - -def column_exists(table_name: str, column_name: str) -> bool: - """Check if a column exists in a table.""" - bind = op.get_bind() - inspector = inspect(bind) - columns = [col["name"] for col in inspector.get_columns(table_name)] - return column_name in columns - - -def index_exists(index_name: str, table_name: str) -> bool: - """Check if an index exists on a table.""" - bind = op.get_bind() - inspector = inspect(bind) - indexes = inspector.get_indexes(table_name) - return any(idx["name"] == index_name for idx in indexes) - +from schema_helpers import column_exists, index_exists # revision identifiers, used by Alembic. revision: str = "a1b2c3d4e5f6" @@ -59,7 +42,7 @@ def upgrade() -> None: ) # Create single-column indexes - if not index_exists("ix_files_relationship_id", "files"): + if not index_exists("files", "ix_files_relationship_id"): op.create_index( "ix_files_relationship_id", "files", @@ -67,7 +50,7 @@ def upgrade() -> None: unique=False, ) - if not index_exists("ix_files_parent_id", "files"): + if not index_exists("files", "ix_files_parent_id"): op.create_index( "ix_files_parent_id", "files", @@ -76,7 +59,7 @@ def upgrade() -> None: ) # Create composite indexes for common query patterns - if not index_exists("ix_relationship_partition", "files"): + if not index_exists("files", "ix_relationship_partition"): op.create_index( "ix_relationship_partition", "files", @@ -84,7 +67,7 @@ def upgrade() -> None: unique=False, ) - if not index_exists("ix_parent_partition", "files"): + if not index_exists("files", "ix_parent_partition"): op.create_index( "ix_parent_partition", "files", @@ -97,17 +80,17 @@ def downgrade() -> None: """Remove relationship_id and parent_id columns from files table.""" # Drop composite indexes - if index_exists("ix_parent_partition", "files"): + if index_exists("files", "ix_parent_partition"): op.drop_index("ix_parent_partition", table_name="files") - if index_exists("ix_relationship_partition", "files"): + if index_exists("files", "ix_relationship_partition"): op.drop_index("ix_relationship_partition", table_name="files") # Drop single-column indexes - if index_exists("ix_files_parent_id", "files"): + if index_exists("files", "ix_files_parent_id"): op.drop_index("ix_files_parent_id", table_name="files") - if index_exists("ix_files_relationship_id", "files"): + if index_exists("files", "ix_files_relationship_id"): op.drop_index("ix_files_relationship_id", table_name="files") # Drop columns diff --git a/openrag/scripts/migrations/alembic/versions/c224d4befe71_add_file_count_and_file_quota.py b/openrag/scripts/migrations/alembic/versions/c224d4befe71_add_file_count_and_file_quota.py index bd842c4a7..31dff99d0 100644 --- a/openrag/scripts/migrations/alembic/versions/c224d4befe71_add_file_count_and_file_quota.py +++ b/openrag/scripts/migrations/alembic/versions/c224d4befe71_add_file_count_and_file_quota.py @@ -10,6 +10,7 @@ import sqlalchemy as sa from alembic import op +from schema_helpers import column_exists, fk_exists, index_exists # revision identifiers, used by Alembic. revision: str = "c224d4befe71" @@ -19,18 +20,32 @@ def upgrade() -> None: - """Upgrade schema.""" - op.add_column("users", sa.Column("file_quota", sa.Integer(), nullable=True)) - op.add_column("users", sa.Column("file_count", sa.Integer(), nullable=False, server_default="0")) - op.add_column("files", sa.Column("created_by", sa.Integer(), nullable=True)) - op.create_foreign_key("fk_files_created_by", "files", "users", ["created_by"], ["id"], ondelete="SET NULL") - op.create_index("ix_files_created_by", "files", ["created_by"]) + """Upgrade schema. + + Idempotent: Base.metadata.create_all() at app startup may have already + added these columns/indexes from the SQLAlchemy models on older deployments. + """ + if not column_exists("users", "file_quota"): + op.add_column("users", sa.Column("file_quota", sa.Integer(), nullable=True)) + if not column_exists("users", "file_count"): + op.add_column("users", sa.Column("file_count", sa.Integer(), nullable=False, server_default="0")) + if not column_exists("files", "created_by"): + op.add_column("files", sa.Column("created_by", sa.Integer(), nullable=True)) + if not fk_exists("files", "fk_files_created_by"): + op.create_foreign_key("fk_files_created_by", "files", "users", ["created_by"], ["id"], ondelete="SET NULL") + if not index_exists("files", "ix_files_created_by"): + op.create_index("ix_files_created_by", "files", ["created_by"]) def downgrade() -> None: """Downgrade schema.""" - op.drop_index("ix_files_created_by", table_name="files") - op.drop_constraint("fk_files_created_by", "files", type_="foreignkey") - op.drop_column("files", "created_by") - op.drop_column("users", "file_count") - op.drop_column("users", "file_quota") + if index_exists("files", "ix_files_created_by"): + op.drop_index("ix_files_created_by", table_name="files") + if fk_exists("files", "fk_files_created_by"): + op.drop_constraint("fk_files_created_by", "files", type_="foreignkey") + if column_exists("files", "created_by"): + op.drop_column("files", "created_by") + if column_exists("users", "file_count"): + op.drop_column("users", "file_count") + if column_exists("users", "file_quota"): + op.drop_column("users", "file_quota") diff --git a/openrag/scripts/migrations/alembic/versions/cd642e4502d8_create_users_memberships_tables.py b/openrag/scripts/migrations/alembic/versions/cd642e4502d8_create_users_memberships_tables.py index 44bfdd1bc..975021ed1 100755 --- a/openrag/scripts/migrations/alembic/versions/cd642e4502d8_create_users_memberships_tables.py +++ b/openrag/scripts/migrations/alembic/versions/cd642e4502d8_create_users_memberships_tables.py @@ -10,23 +10,7 @@ import sqlalchemy as sa from alembic import op -from sqlalchemy import inspect - - -def table_exists(table_name: str) -> bool: - """Check if a table exists in the database.""" - bind = op.get_bind() - inspector = inspect(bind) - return table_name in inspector.get_table_names() - - -def index_exists(index_name: str, table_name: str) -> bool: - """Check if an index exists on a table.""" - bind = op.get_bind() - inspector = inspect(bind) - indexes = inspector.get_indexes(table_name) - return any(idx["name"] == index_name for idx in indexes) - +from schema_helpers import index_exists, table_exists # revision identifiers, used by Alembic. revision: str = "cd642e4502d8" @@ -52,14 +36,14 @@ def upgrade() -> None: # Create indexes for users table if they don't exist if table_exists("users"): - if not index_exists("ix_users_external_user_id", "users"): + if not index_exists("users", "ix_users_external_user_id"): op.create_index( op.f("ix_users_external_user_id"), "users", ["external_user_id"], unique=True, ) - if not index_exists("ix_users_token", "users"): + if not index_exists("users", "ix_users_token"): op.create_index(op.f("ix_users_token"), "users", ["token"], unique=True) # Create partition_memberships table if it doesn't exist @@ -80,21 +64,21 @@ def upgrade() -> None: # Create indexes for partition_memberships table if they don't exist if table_exists("partition_memberships"): - if not index_exists("ix_partition_memberships_partition_name", "partition_memberships"): + if not index_exists("partition_memberships", "ix_partition_memberships_partition_name"): op.create_index( op.f("ix_partition_memberships_partition_name"), "partition_memberships", ["partition_name"], unique=False, ) - if not index_exists("ix_partition_memberships_user_id", "partition_memberships"): + if not index_exists("partition_memberships", "ix_partition_memberships_user_id"): op.create_index( op.f("ix_partition_memberships_user_id"), "partition_memberships", ["user_id"], unique=False, ) - if not index_exists("ix_user_partition", "partition_memberships"): + if not index_exists("partition_memberships", "ix_user_partition"): op.create_index( "ix_user_partition", "partition_memberships", @@ -107,14 +91,14 @@ def downgrade() -> None: """Downgrade schema.""" # Drop indexes and tables if they exist if table_exists("partition_memberships"): - if index_exists("ix_user_partition", "partition_memberships"): + if index_exists("partition_memberships", "ix_user_partition"): op.drop_index("ix_user_partition", table_name="partition_memberships") - if index_exists("ix_partition_memberships_user_id", "partition_memberships"): + if index_exists("partition_memberships", "ix_partition_memberships_user_id"): op.drop_index( op.f("ix_partition_memberships_user_id"), table_name="partition_memberships", ) - if index_exists("ix_partition_memberships_partition_name", "partition_memberships"): + if index_exists("partition_memberships", "ix_partition_memberships_partition_name"): op.drop_index( op.f("ix_partition_memberships_partition_name"), table_name="partition_memberships", @@ -122,8 +106,8 @@ def downgrade() -> None: op.drop_table("partition_memberships") if table_exists("users"): - if index_exists("ix_users_token", "users"): + if index_exists("users", "ix_users_token"): op.drop_index(op.f("ix_users_token"), table_name="users") - if index_exists("ix_users_external_user_id", "users"): + if index_exists("users", "ix_users_external_user_id"): op.drop_index(op.f("ix_users_external_user_id"), table_name="users") op.drop_table("users") diff --git a/openrag/scripts/migrations/alembic/versions/e7f8a9b0c1d2_add_workspaces.py b/openrag/scripts/migrations/alembic/versions/e7f8a9b0c1d2_add_workspaces.py index ca280a3e1..af6a9c9f5 100644 --- a/openrag/scripts/migrations/alembic/versions/e7f8a9b0c1d2_add_workspaces.py +++ b/openrag/scripts/migrations/alembic/versions/e7f8a9b0c1d2_add_workspaces.py @@ -10,6 +10,7 @@ import sqlalchemy as sa from alembic import op +from schema_helpers import table_exists # revision identifiers, used by Alembic. revision: str = "e7f8a9b0c1d2" @@ -19,42 +20,50 @@ def upgrade() -> None: - """Upgrade schema.""" - op.create_table( - "workspaces", - sa.Column("id", sa.Integer, primary_key=True), - sa.Column("workspace_id", sa.String, unique=True, nullable=False, index=True), - sa.Column( - "partition_name", - sa.String, - sa.ForeignKey("partitions.partition", ondelete="CASCADE"), - nullable=False, - ), - sa.Column( - "created_by", - sa.Integer, - sa.ForeignKey("users.id", ondelete="SET NULL"), - nullable=True, - ), - sa.Column("display_name", sa.String, nullable=True), - sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), - ) - op.create_table( - "workspace_files", - sa.Column("id", sa.Integer, primary_key=True), - sa.Column( - "workspace_id", - sa.String, - sa.ForeignKey("workspaces.workspace_id", ondelete="CASCADE"), - nullable=False, - index=True, - ), - sa.Column("file_id", sa.String, nullable=False, index=True), - sa.UniqueConstraint("workspace_id", "file_id", name="uix_workspace_file"), - ) + """Upgrade schema. + + Idempotent: Base.metadata.create_all() at app startup may have already + created these tables on older deployments. + """ + if not table_exists("workspaces"): + op.create_table( + "workspaces", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("workspace_id", sa.String, unique=True, nullable=False, index=True), + sa.Column( + "partition_name", + sa.String, + sa.ForeignKey("partitions.partition", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "created_by", + sa.Integer, + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("display_name", sa.String, nullable=True), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + ) + if not table_exists("workspace_files"): + op.create_table( + "workspace_files", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "workspace_id", + sa.String, + sa.ForeignKey("workspaces.workspace_id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column("file_id", sa.String, nullable=False, index=True), + sa.UniqueConstraint("workspace_id", "file_id", name="uix_workspace_file"), + ) def downgrade() -> None: """Downgrade schema.""" - op.drop_table("workspace_files") - op.drop_table("workspaces") + if table_exists("workspace_files"): + op.drop_table("workspace_files") + if table_exists("workspaces"): + op.drop_table("workspaces") diff --git a/openrag/scripts/migrations/alembic/versions/f1a2b3c4d5e6_add_workspace_files_file_id_fk.py b/openrag/scripts/migrations/alembic/versions/f1a2b3c4d5e6_add_workspace_files_file_id_fk.py index d64acc1e7..d2bff8ba9 100644 --- a/openrag/scripts/migrations/alembic/versions/f1a2b3c4d5e6_add_workspace_files_file_id_fk.py +++ b/openrag/scripts/migrations/alembic/versions/f1a2b3c4d5e6_add_workspace_files_file_id_fk.py @@ -10,6 +10,13 @@ import sqlalchemy as sa from alembic import op +from schema_helpers import ( + column_exists, + column_type_is, + fk_exists, + index_exists, + unique_constraint_exists, +) # revision identifiers, used by Alembic. revision: str = "f1a2b3c4d5e6" @@ -19,55 +26,78 @@ def upgrade() -> None: - """Migrate workspace_files.file_id from string to integer FK referencing files.id.""" + """Migrate workspace_files.file_id from string to integer FK referencing files.id. + + Idempotent: Base.metadata.create_all() at app startup may have already + created workspace_files with file_id as INTEGER on older deployments — in + which case the conversion is a no-op. + """ + if column_type_is("workspace_files", "file_id", sa.Integer): + return + # 1. Purge rows that have no matching file (no valid files.file_id to JOIN against). op.execute("DELETE FROM workspace_files WHERE file_id NOT IN (SELECT file_id FROM files)") # 2. Add a temporary integer column to hold the resolved files.id value. - op.add_column("workspace_files", sa.Column("file_fk", sa.Integer(), nullable=True)) + if not column_exists("workspace_files", "file_fk"): + op.add_column("workspace_files", sa.Column("file_fk", sa.Integer(), nullable=True)) # 3. Populate it by joining on the string file_id, scoped to the workspace's partition # to resolve ambiguity when the same filename exists in multiple partitions. + # All joined tables go in the FROM list — Postgres doesn't allow forward + # references to the UPDATE target (wf) inside a from_item's JOIN ON clause. op.execute( "UPDATE workspace_files wf " "SET file_fk = f.id " - "FROM files f " - "JOIN workspaces w ON w.workspace_id = wf.workspace_id " - "WHERE f.file_id = wf.file_id AND f.partition_name = w.partition_name" + "FROM files f, workspaces w " + "WHERE w.workspace_id = wf.workspace_id " + " AND f.file_id = wf.file_id " + " AND f.partition_name = w.partition_name" ) # 3b. Drop any rows that couldn't be resolved (file_fk still NULL). op.execute("DELETE FROM workspace_files WHERE file_fk IS NULL") # 4. Drop the old string column and its index. - op.drop_index("ix_workspace_files_file_id", table_name="workspace_files") - op.drop_column("workspace_files", "file_id") + if index_exists("workspace_files", "ix_workspace_files_file_id"): + op.drop_index("ix_workspace_files_file_id", table_name="workspace_files") + if column_exists("workspace_files", "file_id"): + op.drop_column("workspace_files", "file_id") # 5. Rename file_fk → file_id, make it NOT NULL. op.alter_column("workspace_files", "file_fk", new_column_name="file_id", nullable=False) # 6. Recreate the index, unique constraint, and FK. - op.create_index("ix_workspace_files_file_id", "workspace_files", ["file_id"]) - op.create_unique_constraint("uix_workspace_file", "workspace_files", ["workspace_id", "file_id"]) - op.create_foreign_key( - "fk_workspace_files_file_id", - "workspace_files", - "files", - ["file_id"], - ["id"], - ondelete="CASCADE", - ) + if not index_exists("workspace_files", "ix_workspace_files_file_id"): + op.create_index("ix_workspace_files_file_id", "workspace_files", ["file_id"]) + if not unique_constraint_exists("workspace_files", "uix_workspace_file"): + op.create_unique_constraint("uix_workspace_file", "workspace_files", ["workspace_id", "file_id"]) + if not fk_exists("workspace_files", "fk_workspace_files_file_id"): + op.create_foreign_key( + "fk_workspace_files_file_id", + "workspace_files", + "files", + ["file_id"], + ["id"], + ondelete="CASCADE", + ) def downgrade() -> None: """Revert workspace_files.file_id back to a string column.""" - op.drop_constraint("fk_workspace_files_file_id", "workspace_files", type_="foreignkey") - op.drop_index("ix_workspace_files_file_id", table_name="workspace_files") + if fk_exists("workspace_files", "fk_workspace_files_file_id"): + op.drop_constraint("fk_workspace_files_file_id", "workspace_files", type_="foreignkey") + if index_exists("workspace_files", "ix_workspace_files_file_id"): + op.drop_index("ix_workspace_files_file_id", table_name="workspace_files") # Re-add a string column and repopulate from files.file_id via JOIN. - op.add_column("workspace_files", sa.Column("file_str", sa.String(), nullable=True)) + if not column_exists("workspace_files", "file_str"): + op.add_column("workspace_files", sa.Column("file_str", sa.String(), nullable=True)) op.execute("UPDATE workspace_files wf SET file_str = f.file_id FROM files f WHERE f.id = wf.file_id") - op.drop_column("workspace_files", "file_id") + if column_exists("workspace_files", "file_id"): + op.drop_column("workspace_files", "file_id") op.alter_column("workspace_files", "file_str", new_column_name="file_id", nullable=False) - op.create_index("ix_workspace_files_file_id", "workspace_files", ["file_id"]) - op.create_unique_constraint("uix_workspace_file", "workspace_files", ["workspace_id", "file_id"]) + if not index_exists("workspace_files", "ix_workspace_files_file_id"): + op.create_index("ix_workspace_files_file_id", "workspace_files", ["file_id"]) + if not unique_constraint_exists("workspace_files", "uix_workspace_file"): + op.create_unique_constraint("uix_workspace_file", "workspace_files", ["workspace_id", "file_id"]) diff --git a/openrag/scripts/migrations/alembic/versions/f5b6c918f741_add_oidc_auth.py b/openrag/scripts/migrations/alembic/versions/f5b6c918f741_add_oidc_auth.py index 6a88f98d8..d8d573a0c 100644 --- a/openrag/scripts/migrations/alembic/versions/f5b6c918f741_add_oidc_auth.py +++ b/openrag/scripts/migrations/alembic/versions/f5b6c918f741_add_oidc_auth.py @@ -10,6 +10,7 @@ import sqlalchemy as sa from alembic import op +from schema_helpers import column_exists, index_exists, table_exists # revision identifiers, used by Alembic. revision: str = "f5b6c918f741" @@ -19,59 +20,71 @@ def upgrade() -> None: - """Upgrade schema: add users.email column and create oidc_sessions table.""" + """Upgrade schema: add users.email column and create oidc_sessions table. + + Idempotent: Base.metadata.create_all() at app startup may have already + created these on older deployments. + """ # users.email (nullable, unique, indexed) - op.add_column("users", sa.Column("email", sa.String(), nullable=True)) - op.create_index("ix_users_email", "users", ["email"], unique=True) + if not column_exists("users", "email"): + op.add_column("users", sa.Column("email", sa.String(), nullable=True)) + if not index_exists("users", "ix_users_email"): + op.create_index("ix_users_email", "users", ["email"], unique=True) # oidc_sessions table - op.create_table( - "oidc_sessions", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column( - "session_token_hash", - sa.String(length=64), - nullable=False, - unique=True, - index=True, - ), - sa.Column( - "user_id", - sa.Integer(), - sa.ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - index=True, - ), - sa.Column("sid", sa.String(), nullable=True, index=True), - sa.Column("sub", sa.String(), nullable=False), - sa.Column("id_token_encrypted", sa.LargeBinary(), nullable=True), - sa.Column("access_token_encrypted", sa.LargeBinary(), nullable=True), - sa.Column("refresh_token_encrypted", sa.LargeBinary(), nullable=True), - sa.Column("access_token_expires_at", sa.DateTime(), nullable=False), - sa.Column("session_expires_at", sa.DateTime(), nullable=False), - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.func.now(), - ), - sa.Column("last_refresh_at", sa.DateTime(), nullable=True), - sa.Column("revoked_at", sa.DateTime(), nullable=True), - ) + if not table_exists("oidc_sessions"): + op.create_table( + "oidc_sessions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "session_token_hash", + sa.String(length=64), + nullable=False, + unique=True, + index=True, + ), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column("sid", sa.String(), nullable=True, index=True), + sa.Column("sub", sa.String(), nullable=False), + sa.Column("id_token_encrypted", sa.LargeBinary(), nullable=True), + sa.Column("access_token_encrypted", sa.LargeBinary(), nullable=True), + sa.Column("refresh_token_encrypted", sa.LargeBinary(), nullable=True), + sa.Column("access_token_expires_at", sa.DateTime(), nullable=False), + sa.Column("session_expires_at", sa.DateTime(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("last_refresh_at", sa.DateTime(), nullable=True), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + ) # Composite index (user_id, sub) for fast lookup by (user, OIDC subject). # The individual session_token_hash, user_id, and sid indexes are already # created implicitly via the Column(..., index=True/unique=True) directives above. - op.create_index( - "ix_oidc_sessions_user_sub", - "oidc_sessions", - ["user_id", "sub"], - ) + if not index_exists("oidc_sessions", "ix_oidc_sessions_user_sub"): + op.create_index( + "ix_oidc_sessions_user_sub", + "oidc_sessions", + ["user_id", "sub"], + ) def downgrade() -> None: """Downgrade schema: drop oidc_sessions table and users.email column.""" - op.drop_index("ix_oidc_sessions_user_sub", table_name="oidc_sessions") + if index_exists("oidc_sessions", "ix_oidc_sessions_user_sub"): + op.drop_index("ix_oidc_sessions_user_sub", table_name="oidc_sessions") # Drop table — this cascades the implicit per-column indexes. - op.drop_table("oidc_sessions") - op.drop_index("ix_users_email", table_name="users") - op.drop_column("users", "email") + if table_exists("oidc_sessions"): + op.drop_table("oidc_sessions") + if index_exists("users", "ix_users_email"): + op.drop_index("ix_users_email", table_name="users") + if column_exists("users", "email"): + op.drop_column("users", "email")