From 8ca774555cac5fd7366baaefafbb88c9e425df8e Mon Sep 17 00:00:00 2001 From: Harsh Kumar Date: Sun, 1 Mar 2026 21:04:10 +0530 Subject: [PATCH 1/2] feat: add Alembic database migrations Replace Base.metadata.create_all() with Alembic for versioned schema management. Migrations run automatically on startup, with fallback to create_all if Alembic fails. Pre-Alembic databases are auto-detected and stamped so existing deployments upgrade seamlessly. Signed-off-by: Harsh Kumar --- README.md | 13 ++++- backend/Makefile | 20 +++++++ backend/alembic.ini | 43 ++++++++++++++ backend/pyproject.toml | 1 + backend/src/database/alembic/env.py | 52 +++++++++++++++++ backend/src/database/alembic/script.py.mako | 26 +++++++++ .../alembic/versions/0001_initial_schema.py | 56 +++++++++++++++++++ backend/src/database/config.py | 40 ++++++++++--- backend/tests/test_database_config.py | 34 ++++------- backend/uv.lock | 30 +++++++++- 10 files changed, 281 insertions(+), 34 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/src/database/alembic/env.py create mode 100644 backend/src/database/alembic/script.py.mako create mode 100644 backend/src/database/alembic/versions/0001_initial_schema.py diff --git a/README.md b/README.md index 799536c3..d49bc4e1 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,19 @@ This setup involves the setting of both the frontend and backend components. We #### Database Schema -The database automatically creates the following tables: -- `conversations` - Stores conversation metadata (id, user_id, title, timestamps) +The database uses [Alembic](https://alembic.sqlalchemy.org/) for schema migrations. On startup, the backend automatically applies any pending migrations. The following tables are managed: +- `conversations` - Stores conversation metadata (id, title, timestamps) - `messages` - Stores individual messages within conversations +**Database Migration Commands** (from `backend/`): +```bash +make db-migrate # Apply pending migrations +make db-revision msg="description" # Generate a new migration from model changes +make db-downgrade # Roll back one migration +make db-history # Show migration history +make db-current # Show current revision +``` + #### Setting Up PostgreSQL Database Variables The backend uses PostgreSQL for conversation persistence. Configure these database variables in your `.env` file: diff --git a/backend/Makefile b/backend/Makefile index 0088ce3a..acef0e8a 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -33,3 +33,23 @@ mcp: .PHONY: chat chat: @uv run python -m chatbot + +.PHONY: db-migrate +db-migrate: + @uv run alembic upgrade head + +.PHONY: db-revision +db-revision: + @uv run alembic revision --autogenerate -m "$(msg)" + +.PHONY: db-downgrade +db-downgrade: + @uv run alembic downgrade -1 + +.PHONY: db-history +db-history: + @uv run alembic history + +.PHONY: db-current +db-current: + @uv run alembic current diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 00000000..cedfd3de --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,43 @@ +[alembic] +script_location = src/database/alembic +prepend_sys_path = . +version_path_separator = os + +# sqlalchemy.url is set programmatically in env.py +sqlalchemy.url = + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9f59fa37..83f45a7f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,6 +5,7 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "alembic>=1.13.0", "asyncpg>=0.30.0", "cryptography>=46.0.5", "faiss-cpu==1.12.0", diff --git a/backend/src/database/alembic/env.py b/backend/src/database/alembic/env.py new file mode 100644 index 00000000..2b1daac2 --- /dev/null +++ b/backend/src/database/alembic/env.py @@ -0,0 +1,52 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context + +from src.database.config import get_database_url +from src.database.models import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", get_database_url()) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/src/database/alembic/script.py.mako b/backend/src/database/alembic/script.py.mako new file mode 100644 index 00000000..fbc4b07d --- /dev/null +++ b/backend/src/database/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/src/database/alembic/versions/0001_initial_schema.py b/backend/src/database/alembic/versions/0001_initial_schema.py new file mode 100644 index 00000000..2ef89c93 --- /dev/null +++ b/backend/src/database/alembic/versions/0001_initial_schema.py @@ -0,0 +1,56 @@ +"""Initial schema + +Revision ID: 0001 +Revises: +Create Date: 2026-03-01 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "conversations", + sa.Column("uuid", sa.Uuid(), primary_key=True), + sa.Column("title", sa.String(500), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + + op.create_table( + "messages", + sa.Column("uuid", sa.Uuid(), primary_key=True), + sa.Column( + "conversation_uuid", + sa.Uuid(), + sa.ForeignKey("conversations.uuid", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("role", sa.String(50), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("context_sources", sa.JSON(), nullable=True), + sa.Column("tools", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + + op.create_index( + "ix_messages_conversation_uuid", + "messages", + ["conversation_uuid"], + ) + + +def downgrade() -> None: + op.drop_index("ix_messages_conversation_uuid", table_name="messages") + op.drop_table("messages") + op.drop_table("conversations") diff --git a/backend/src/database/config.py b/backend/src/database/config.py index 417b314a..3cf3fb29 100644 --- a/backend/src/database/config.py +++ b/backend/src/database/config.py @@ -2,6 +2,7 @@ import os import logging +from pathlib import Path from typing import Generator, Optional from sqlalchemy import create_engine, inspect, text, Engine from sqlalchemy.orm import sessionmaker, Session @@ -40,6 +41,32 @@ def is_database_available() -> bool: return False +def run_migrations() -> None: + """Run Alembic migrations to bring the database schema up to date. + + Auto-detects pre-Alembic databases (app tables exist but no alembic_version + table) and stamps them with the current head revision so that future + migrations apply cleanly. + """ + from alembic.config import Config + from alembic import command + + alembic_ini = Path(__file__).resolve().parents[2] / "alembic.ini" + alembic_cfg = Config(str(alembic_ini)) + + # Stamp pre-Alembic databases so migrations don't try to recreate tables + inspector = inspect(engine) + existing_tables = inspector.get_table_names() + has_app_tables = "conversations" in existing_tables + has_alembic = "alembic_version" in existing_tables + + if has_app_tables and not has_alembic: + logger.info("Pre-Alembic database detected — stamping with current head.") + command.stamp(alembic_cfg, "head") + else: + command.upgrade(alembic_cfg, "head") + + def init_database() -> bool: global engine, SessionLocal, _db_initialized @@ -64,15 +91,12 @@ def init_database() -> bool: logger.warning("Database is not available. Will retry on next access.") return False - inspector = inspect(engine) - existing_tables = inspector.get_table_names() - - if not existing_tables or "conversations" not in existing_tables: - logger.info("Initializing database tables...") + try: + run_migrations() + except Exception as e: + logger.warning(f"Alembic migration failed: {e}") + logger.warning("Falling back to create_all for table creation.") Base.metadata.create_all(bind=engine) - logger.info("Database tables created successfully") - else: - logger.debug("Database tables already exist") _db_initialized = True return True diff --git a/backend/tests/test_database_config.py b/backend/tests/test_database_config.py index a993c4ed..9845da21 100644 --- a/backend/tests/test_database_config.py +++ b/backend/tests/test_database_config.py @@ -121,12 +121,9 @@ def test_init_database_creates_engine(self): }, ), patch("src.database.config.create_engine") as mock_create_engine, patch( "src.database.config.is_database_available", return_value=True - ), patch("src.database.config.inspect") as mock_inspect: + ), patch("src.database.config.run_migrations"): mock_engine = Mock() mock_create_engine.return_value = mock_engine - mock_inspector = Mock() - mock_inspector.get_table_names.return_value = ["conversations", "messages"] - mock_inspect.return_value = mock_inspector result = init_database() @@ -140,8 +137,8 @@ def test_init_database_creates_engine(self): assert call_kwargs["echo"] is False assert call_kwargs["connect_args"]["connect_timeout"] == 5 - def test_init_database_creates_tables_when_not_exist(self): - """Test init_database creates tables when they don't exist.""" + def test_init_database_runs_migrations(self): + """Test init_database calls run_migrations.""" self.setUp() with patch.dict( @@ -155,22 +152,17 @@ def test_init_database_creates_tables_when_not_exist(self): }, ), patch("src.database.config.create_engine") as mock_create_engine, patch( "src.database.config.is_database_available", return_value=True - ), patch("src.database.config.inspect") as mock_inspect, patch( - "src.database.config.Base" - ) as mock_base: + ), patch("src.database.config.run_migrations") as mock_run_migrations: mock_engine = Mock() mock_create_engine.return_value = mock_engine - mock_inspector = Mock() - mock_inspector.get_table_names.return_value = [] - mock_inspect.return_value = mock_inspector result = init_database() assert result is True - mock_base.metadata.create_all.assert_called_once_with(bind=mock_engine) + mock_run_migrations.assert_called_once() - def test_init_database_skips_table_creation_when_exist(self): - """Test init_database skips table creation when they already exist.""" + def test_init_database_falls_back_to_create_all_on_migration_failure(self): + """Test init_database falls back to create_all when migrations fail.""" self.setUp() with patch.dict( @@ -184,19 +176,17 @@ def test_init_database_skips_table_creation_when_exist(self): }, ), patch("src.database.config.create_engine") as mock_create_engine, patch( "src.database.config.is_database_available", return_value=True - ), patch("src.database.config.inspect") as mock_inspect, patch( - "src.database.config.Base" - ) as mock_base: + ), patch( + "src.database.config.run_migrations", + side_effect=Exception("migration error"), + ), patch("src.database.config.Base") as mock_base: mock_engine = Mock() mock_create_engine.return_value = mock_engine - mock_inspector = Mock() - mock_inspector.get_table_names.return_value = ["conversations", "messages"] - mock_inspect.return_value = mock_inspector result = init_database() assert result is True - mock_base.metadata.create_all.assert_not_called() + mock_base.metadata.create_all.assert_called_once_with(bind=mock_engine) def test_init_database_returns_false_when_unavailable(self): """Test init_database returns False when database is unavailable.""" diff --git a/backend/uv.lock b/backend/uv.lock index 3fbff24a..d47721e8 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -125,6 +125,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -207,6 +221,7 @@ name = "backend" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "alembic" }, { name = "asyncpg" }, { name = "cryptography" }, { name = "faiss-cpu" }, @@ -266,6 +281,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.13.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "cryptography", specifier = ">=46.0.5" }, { name = "faiss-cpu", specifier = "==1.12.0" }, @@ -1178,7 +1194,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, @@ -1189,7 +1204,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, @@ -1944,6 +1958,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, ] +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + [[package]] name = "markdown" version = "3.8.2" From 09b198282c9fb288a341e83245a51ae71960f273 Mon Sep 17 00:00:00 2001 From: Harsh Kumar Date: Sun, 1 Mar 2026 21:17:42 +0530 Subject: [PATCH 2/2] fix: address review feedback on Alembic migrations - Pass disable_existing_loggers=False in env.py to preserve app logging - Use exc_info=True for migration failure logging to capture full traceback - Fix README column description (id -> uuid) - Add unit tests for run_migrations() stamp vs upgrade behavior Signed-off-by: Harsh Kumar --- README.md | 2 +- backend/src/database/alembic/env.py | 2 +- backend/src/database/config.py | 8 ++-- backend/tests/test_database_config.py | 63 +++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d49bc4e1..d444b8cd 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ This setup involves the setting of both the frontend and backend components. We #### Database Schema The database uses [Alembic](https://alembic.sqlalchemy.org/) for schema migrations. On startup, the backend automatically applies any pending migrations. The following tables are managed: -- `conversations` - Stores conversation metadata (id, title, timestamps) +- `conversations` - Stores conversation metadata (uuid, title, timestamps) - `messages` - Stores individual messages within conversations **Database Migration Commands** (from `backend/`): diff --git a/backend/src/database/alembic/env.py b/backend/src/database/alembic/env.py index 2b1daac2..db804791 100644 --- a/backend/src/database/alembic/env.py +++ b/backend/src/database/alembic/env.py @@ -10,7 +10,7 @@ config = context.config if config.config_file_name is not None: - fileConfig(config.config_file_name) + fileConfig(config.config_file_name, disable_existing_loggers=False) config.set_main_option("sqlalchemy.url", get_database_url()) diff --git a/backend/src/database/config.py b/backend/src/database/config.py index 3cf3fb29..8dc362d6 100644 --- a/backend/src/database/config.py +++ b/backend/src/database/config.py @@ -93,9 +93,11 @@ def init_database() -> bool: try: run_migrations() - except Exception as e: - logger.warning(f"Alembic migration failed: {e}") - logger.warning("Falling back to create_all for table creation.") + except Exception: + logger.warning( + "Alembic migration failed; falling back to create_all for table creation.", + exc_info=True, + ) Base.metadata.create_all(bind=engine) _db_initialized = True diff --git a/backend/tests/test_database_config.py b/backend/tests/test_database_config.py index 9845da21..e55f62bd 100644 --- a/backend/tests/test_database_config.py +++ b/backend/tests/test_database_config.py @@ -10,6 +10,7 @@ is_database_available, init_database, get_db, + run_migrations, ) @@ -83,6 +84,68 @@ def test_is_database_available_connection_fails(self): assert result is False +class TestRunMigrations: + """Test suite for Alembic migration logic.""" + + def test_run_migrations_stamps_pre_alembic_database(self): + """Test that pre-Alembic databases (app tables, no alembic_version) get stamped.""" + mock_engine = Mock() + mock_inspector = Mock() + mock_inspector.get_table_names.return_value = ["conversations", "messages"] + + with patch.object(config, "engine", mock_engine), patch( + "src.database.config.inspect", return_value=mock_inspector + ), patch("src.database.config.Path"), patch( + "alembic.command.stamp" + ) as mock_stamp, patch("alembic.command.upgrade") as mock_upgrade, patch( + "alembic.config.Config" + ): + run_migrations() + + mock_stamp.assert_called_once() + mock_upgrade.assert_not_called() + + def test_run_migrations_upgrades_fresh_database(self): + """Test that a fresh database (no tables) runs upgrade.""" + mock_engine = Mock() + mock_inspector = Mock() + mock_inspector.get_table_names.return_value = [] + + with patch.object(config, "engine", mock_engine), patch( + "src.database.config.inspect", return_value=mock_inspector + ), patch("src.database.config.Path"), patch( + "alembic.command.stamp" + ) as mock_stamp, patch("alembic.command.upgrade") as mock_upgrade, patch( + "alembic.config.Config" + ): + run_migrations() + + mock_upgrade.assert_called_once() + mock_stamp.assert_not_called() + + def test_run_migrations_upgrades_already_versioned_database(self): + """Test that a database with alembic_version runs upgrade (not stamp).""" + mock_engine = Mock() + mock_inspector = Mock() + mock_inspector.get_table_names.return_value = [ + "conversations", + "messages", + "alembic_version", + ] + + with patch.object(config, "engine", mock_engine), patch( + "src.database.config.inspect", return_value=mock_inspector + ), patch("src.database.config.Path"), patch( + "alembic.command.stamp" + ) as mock_stamp, patch("alembic.command.upgrade") as mock_upgrade, patch( + "alembic.config.Config" + ): + run_migrations() + + mock_upgrade.assert_called_once() + mock_stamp.assert_not_called() + + class TestInitDatabase: """Test suite for database initialization."""