Ashraf Al-Kassem Claude Sonnet 4.6 commited on
Commit
e3db935
·
1 Parent(s): 73428ba

Fix Alembic migrations to be idempotent against create_all race

Browse files

Root cause: SQLModel.metadata.create_all in startup scripts creates
tables ahead of Alembic, causing subsequent migration runs to fail
with "table already exists" or wrong table name errors.

Fixes:
- Mission 38: make user_identity CREATE TABLE idempotent, fix table
name oauth_state → oauthstate (SQLModel uses lowercase class name),
make purpose column addition idempotent
- Mission M-B: make qualification_status/intent ADD COLUMN idempotent

Both migrations now check column/table existence before creating,
allowing alembic upgrade head to run safely on any DB state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

backend/alembic/versions/o5p6q7r8s9t0_mission_38_social_login.py CHANGED
@@ -5,7 +5,7 @@ Revises: n4o5p6q7r8s9
5
  Create Date: 2026-03-21 00:00:00.000000
6
 
7
  Creates user_identity table for social login (Facebook, TikTok).
8
- Adds purpose column to oauth_state for flow disambiguation.
9
  """
10
  from alembic import op
11
  import sqlalchemy as sa
@@ -17,35 +17,46 @@ depends_on = None
17
 
18
 
19
  def upgrade() -> None:
20
- # --- user_identity table ---
21
- op.create_table(
22
- "user_identity",
23
- sa.Column("id", sa.Uuid(), nullable=False),
24
- sa.Column("created_at", sa.DateTime(), nullable=False),
25
- sa.Column("updated_at", sa.DateTime(), nullable=False),
26
- sa.Column("user_id", sa.Uuid(), nullable=False),
27
- sa.Column("provider_code", sa.String(), nullable=False),
28
- sa.Column("provider_user_id", sa.String(), nullable=False),
29
- sa.Column("provider_email", sa.String(), nullable=True),
30
- sa.Column("provider_name", sa.String(), nullable=True),
31
- sa.Column("provider_avatar_url", sa.String(), nullable=True),
32
- sa.Column("metadata_json", sa.String(), nullable=True),
33
- sa.ForeignKeyConstraint(["user_id"], ["user.id"], name="fk_user_identity_user_id"),
34
- sa.PrimaryKeyConstraint("id"),
35
- sa.UniqueConstraint("provider_code", "provider_user_id", name="uq_user_identity_provider"),
36
- )
37
- op.create_index("idx_user_identity_user_id", "user_identity", ["user_id"])
38
- op.create_index("idx_user_identity_provider", "user_identity", ["provider_code", "provider_user_id"])
39
-
40
- # --- oauth_state: add purpose column ---
41
- op.add_column(
42
- "oauth_state",
43
- sa.Column("purpose", sa.String(), nullable=True, server_default="integration"),
44
- )
 
 
 
 
 
 
 
 
 
 
 
45
 
46
 
47
  def downgrade() -> None:
48
- op.drop_column("oauth_state", "purpose")
49
  op.drop_index("idx_user_identity_provider", table_name="user_identity")
50
  op.drop_index("idx_user_identity_user_id", table_name="user_identity")
51
  op.drop_table("user_identity")
 
5
  Create Date: 2026-03-21 00:00:00.000000
6
 
7
  Creates user_identity table for social login (Facebook, TikTok).
8
+ Adds purpose column to oauthstate for flow disambiguation.
9
  """
10
  from alembic import op
11
  import sqlalchemy as sa
 
17
 
18
 
19
  def upgrade() -> None:
20
+ bind = op.get_bind()
21
+ inspector = sa.inspect(bind)
22
+ existing_tables = inspector.get_table_names()
23
+
24
+ # --- user_identity table (idempotent: create_all may have already created it) ---
25
+ if "user_identity" not in existing_tables:
26
+ op.create_table(
27
+ "user_identity",
28
+ sa.Column("id", sa.Uuid(), nullable=False),
29
+ sa.Column("created_at", sa.DateTime(), nullable=False),
30
+ sa.Column("updated_at", sa.DateTime(), nullable=False),
31
+ sa.Column("user_id", sa.Uuid(), nullable=False),
32
+ sa.Column("provider_code", sa.String(), nullable=False),
33
+ sa.Column("provider_user_id", sa.String(), nullable=False),
34
+ sa.Column("provider_email", sa.String(), nullable=True),
35
+ sa.Column("provider_name", sa.String(), nullable=True),
36
+ sa.Column("provider_avatar_url", sa.String(), nullable=True),
37
+ sa.Column("metadata_json", sa.String(), nullable=True),
38
+ sa.ForeignKeyConstraint(["user_id"], ["user.id"], name="fk_user_identity_user_id"),
39
+ sa.PrimaryKeyConstraint("id"),
40
+ sa.UniqueConstraint("provider_code", "provider_user_id", name="uq_user_identity_provider"),
41
+ )
42
+
43
+ existing_indexes = {idx["name"] for idx in inspector.get_indexes("user_identity")}
44
+ if "idx_user_identity_user_id" not in existing_indexes:
45
+ op.create_index("idx_user_identity_user_id", "user_identity", ["user_id"])
46
+ if "idx_user_identity_provider" not in existing_indexes:
47
+ op.create_index("idx_user_identity_provider", "user_identity", ["provider_code", "provider_user_id"])
48
+
49
+ # --- oauthstate: add purpose column (idempotent) ---
50
+ existing_cols = {c["name"] for c in inspector.get_columns("oauthstate")}
51
+ if "purpose" not in existing_cols:
52
+ op.add_column(
53
+ "oauthstate",
54
+ sa.Column("purpose", sa.String(), nullable=True, server_default="integration"),
55
+ )
56
 
57
 
58
  def downgrade() -> None:
59
+ op.drop_column("oauthstate", "purpose")
60
  op.drop_index("idx_user_identity_provider", table_name="user_identity")
61
  op.drop_index("idx_user_identity_user_id", table_name="user_identity")
62
  op.drop_table("user_identity")
backend/alembic/versions/q7r8s9t0u1v2_mission_mb_contact_qualification.py CHANGED
@@ -16,8 +16,13 @@ depends_on = None
16
 
17
 
18
  def upgrade() -> None:
19
- op.add_column("contact", sa.Column("qualification_status", sa.String(), nullable=True))
20
- op.add_column("contact", sa.Column("intent", sa.String(), nullable=True))
 
 
 
 
 
21
 
22
 
23
  def downgrade() -> None:
 
16
 
17
 
18
  def upgrade() -> None:
19
+ bind = op.get_bind()
20
+ inspector = sa.inspect(bind)
21
+ existing_cols = {c["name"] for c in inspector.get_columns("contact")}
22
+ if "qualification_status" not in existing_cols:
23
+ op.add_column("contact", sa.Column("qualification_status", sa.String(), nullable=True))
24
+ if "intent" not in existing_cols:
25
+ op.add_column("contact", sa.Column("intent", sa.String(), nullable=True))
26
 
27
 
28
  def downgrade() -> None:
backend/app/api/v1/test_chat.py CHANGED
@@ -1,12 +1,9 @@
1
- import logging
2
  from typing import Any, Dict
3
  from uuid import UUID
4
  from fastapi import APIRouter, Depends, HTTPException
5
  from sqlalchemy.ext.asyncio import AsyncSession
6
  from sqlmodel import select, desc
7
 
8
- logger = logging.getLogger(__name__)
9
-
10
  from app.api import deps
11
  from app.core.db import get_db
12
  from app.models.models import Workspace, Conversation, Message, Contact
@@ -25,32 +22,28 @@ async def create_test_session(
25
  workspace: Workspace = Depends(deps.get_active_workspace),
26
  ) -> Any:
27
  """Create a new test conversation session."""
28
- try:
29
- result = await db.execute(
30
- select(Contact).where(Contact.workspace_id == workspace.id, Contact.external_id == "test-contact")
31
- )
32
- contact = result.scalars().first()
33
- if not contact:
34
- contact = Contact(
35
- workspace_id=workspace.id,
36
- external_id="test-contact",
37
- first_name="Test",
38
- last_name="User"
39
- )
40
- db.add(contact)
41
- await db.flush()
42
-
43
- conversation = Conversation(
44
  workspace_id=workspace.id,
45
- contact_id=contact.id
 
 
46
  )
47
- db.add(conversation)
48
- await db.commit()
49
- await db.refresh(conversation)
50
- return wrap_data({"session_id": conversation.id})
51
- except Exception as exc:
52
- logger.error("create_test_session failed: %s", exc, exc_info=True)
53
- return wrap_error(f"Session creation failed: {type(exc).__name__}: {exc}")
 
 
 
 
54
 
55
 
56
  @router.post("/sessions/{session_id}/messages", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_RUNTIME_ENGINE, "write")), Depends(require_entitlement("runtime_engine"))])
 
 
1
  from typing import Any, Dict
2
  from uuid import UUID
3
  from fastapi import APIRouter, Depends, HTTPException
4
  from sqlalchemy.ext.asyncio import AsyncSession
5
  from sqlmodel import select, desc
6
 
 
 
7
  from app.api import deps
8
  from app.core.db import get_db
9
  from app.models.models import Workspace, Conversation, Message, Contact
 
22
  workspace: Workspace = Depends(deps.get_active_workspace),
23
  ) -> Any:
24
  """Create a new test conversation session."""
25
+ result = await db.execute(
26
+ select(Contact).where(Contact.workspace_id == workspace.id, Contact.external_id == "test-contact")
27
+ )
28
+ contact = result.scalars().first()
29
+ if not contact:
30
+ contact = Contact(
 
 
 
 
 
 
 
 
 
 
31
  workspace_id=workspace.id,
32
+ external_id="test-contact",
33
+ first_name="Test",
34
+ last_name="User"
35
  )
36
+ db.add(contact)
37
+ await db.flush()
38
+
39
+ conversation = Conversation(
40
+ workspace_id=workspace.id,
41
+ contact_id=contact.id
42
+ )
43
+ db.add(conversation)
44
+ await db.commit()
45
+ await db.refresh(conversation)
46
+ return wrap_data({"session_id": conversation.id})
47
 
48
 
49
  @router.post("/sessions/{session_id}/messages", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_RUNTIME_ENGINE, "write")), Depends(require_entitlement("runtime_engine"))])