|
| 1 | +# Database Migration Guidelines |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +This project uses **Alembic** for database migrations with **SQLModel** models. Alembic is the industry-standard migration tool for SQLAlchemy/SQLModel projects. |
| 6 | + |
| 7 | +**CRITICAL**: SQL migrations are the single source of truth for database schema. All table creation and schema changes MUST go through Alembic migrations. |
| 8 | + |
| 9 | +## Architecture |
| 10 | + |
| 11 | +``` |
| 12 | +┌─────────────────────────────────────────────────────────────┐ |
| 13 | +│ SQLModel Models (src/policyengine_api/models/) │ |
| 14 | +│ - Define Python classes │ |
| 15 | +│ - Used for ORM queries │ |
| 16 | +│ - NOT the source of truth for schema │ |
| 17 | +└─────────────────────────────────────────────────────────────┘ |
| 18 | + │ |
| 19 | + │ alembic revision --autogenerate |
| 20 | + ▼ |
| 21 | +┌─────────────────────────────────────────────────────────────┐ |
| 22 | +│ Alembic Migrations (alembic/versions/) │ |
| 23 | +│ - Create/alter tables │ |
| 24 | +│ - Add indexes, constraints │ |
| 25 | +│ - SOURCE OF TRUTH for schema │ |
| 26 | +└─────────────────────────────────────────────────────────────┘ |
| 27 | + │ |
| 28 | + │ alembic upgrade head |
| 29 | + ▼ |
| 30 | +┌─────────────────────────────────────────────────────────────┐ |
| 31 | +│ PostgreSQL Database (Supabase) │ |
| 32 | +│ - Actual schema │ |
| 33 | +│ - Tracked by alembic_version table │ |
| 34 | +└─────────────────────────────────────────────────────────────┘ |
| 35 | +``` |
| 36 | + |
| 37 | +## Essential Rules |
| 38 | + |
| 39 | +### 1. NEVER use SQLModel.metadata.create_all() for schema creation |
| 40 | + |
| 41 | +The old pattern of using `SQLModel.metadata.create_all()` is deprecated. All tables are created via Alembic migrations. |
| 42 | + |
| 43 | +### 2. Every schema change requires a migration |
| 44 | + |
| 45 | +When you modify a SQLModel model (add column, change type, add index), you MUST: |
| 46 | +1. Update the model in `src/policyengine_api/models/` |
| 47 | +2. Generate a migration: `uv run alembic revision --autogenerate -m "Description"` |
| 48 | +3. **Read and verify the generated migration** (see below) |
| 49 | +4. Apply it: `uv run alembic upgrade head` |
| 50 | + |
| 51 | +### 3. ALWAYS verify auto-generated migrations before applying |
| 52 | + |
| 53 | +**This is critical for AI agents.** After running `alembic revision --autogenerate`, you MUST: |
| 54 | + |
| 55 | +1. **Read the generated migration file** in `alembic/versions/` |
| 56 | +2. **Verify the `upgrade()` function** contains the expected changes: |
| 57 | + - Correct table/column names |
| 58 | + - Correct column types (e.g., `sa.String()`, `sa.Uuid()`, `sa.Integer()`) |
| 59 | + - Proper foreign key references |
| 60 | + - Appropriate nullable settings |
| 61 | +3. **Verify the `downgrade()` function** properly reverses the changes |
| 62 | +4. **Check for Alembic autogenerate limitations:** |
| 63 | + - It may miss renamed columns (shows as drop + add instead) |
| 64 | + - It may not detect some index changes |
| 65 | + - It doesn't handle data migrations |
| 66 | +5. **Edit the migration if needed** before applying |
| 67 | + |
| 68 | +Example verification: |
| 69 | +```python |
| 70 | +# Generated migration - verify this looks correct: |
| 71 | +def upgrade() -> None: |
| 72 | + op.add_column('users', sa.Column('phone', sa.String(), nullable=True)) |
| 73 | + |
| 74 | +def downgrade() -> None: |
| 75 | + op.drop_column('users', 'phone') |
| 76 | +``` |
| 77 | + |
| 78 | +**Never blindly apply a migration without reading it first.** |
| 79 | + |
| 80 | +### 4. Migrations must be self-contained |
| 81 | + |
| 82 | +Each migration should: |
| 83 | +- Create tables it needs (never assume they exist from Python) |
| 84 | +- Include both `upgrade()` and `downgrade()` functions |
| 85 | +- Be idempotent where possible (use `IF NOT EXISTS` patterns) |
| 86 | + |
| 87 | +### 5. Never use conditional logic based on table existence |
| 88 | + |
| 89 | +Migrations should NOT check if tables exist. Instead: |
| 90 | +- Ensure migrations run in the correct order (use `down_revision`) |
| 91 | +- The initial migration creates all base tables |
| 92 | +- Subsequent migrations build on that foundation |
| 93 | + |
| 94 | +## Common Commands |
| 95 | + |
| 96 | +```bash |
| 97 | +# Apply all pending migrations |
| 98 | +uv run alembic upgrade head |
| 99 | + |
| 100 | +# Generate migration from model changes |
| 101 | +uv run alembic revision --autogenerate -m "Add users email index" |
| 102 | + |
| 103 | +# Create empty migration (for manual SQL) |
| 104 | +uv run alembic revision -m "Add custom index" |
| 105 | + |
| 106 | +# Check current migration state |
| 107 | +uv run alembic current |
| 108 | + |
| 109 | +# Show migration history |
| 110 | +uv run alembic history |
| 111 | + |
| 112 | +# Downgrade one revision |
| 113 | +uv run alembic downgrade -1 |
| 114 | + |
| 115 | +# Downgrade to specific revision |
| 116 | +uv run alembic downgrade <revision_id> |
| 117 | +``` |
| 118 | + |
| 119 | +## Local Development Workflow |
| 120 | + |
| 121 | +```bash |
| 122 | +# 1. Start Supabase |
| 123 | +supabase start |
| 124 | + |
| 125 | +# 2. Initialize database (runs migrations + applies RLS policies) |
| 126 | +uv run python scripts/init.py |
| 127 | + |
| 128 | +# 3. Seed data |
| 129 | +uv run python scripts/seed.py |
| 130 | +``` |
| 131 | + |
| 132 | +### Reset database (DESTRUCTIVE) |
| 133 | + |
| 134 | +```bash |
| 135 | +uv run python scripts/init.py --reset |
| 136 | +``` |
| 137 | + |
| 138 | +## Adding a New Model |
| 139 | + |
| 140 | +1. Create the model in `src/policyengine_api/models/` |
| 141 | + |
| 142 | +```python |
| 143 | +# src/policyengine_api/models/my_model.py |
| 144 | +from sqlmodel import SQLModel, Field |
| 145 | +from uuid import UUID, uuid4 |
| 146 | + |
| 147 | +class MyModel(SQLModel, table=True): |
| 148 | + __tablename__ = "my_models" |
| 149 | + |
| 150 | + id: UUID = Field(default_factory=uuid4, primary_key=True) |
| 151 | + name: str |
| 152 | +``` |
| 153 | + |
| 154 | +2. Export in `__init__.py`: |
| 155 | + |
| 156 | +```python |
| 157 | +# src/policyengine_api/models/__init__.py |
| 158 | +from .my_model import MyModel |
| 159 | +``` |
| 160 | + |
| 161 | +3. Generate migration: |
| 162 | + |
| 163 | +```bash |
| 164 | +uv run alembic revision --autogenerate -m "Add my_models table" |
| 165 | +``` |
| 166 | + |
| 167 | +4. Review the generated migration in `alembic/versions/` |
| 168 | + |
| 169 | +5. Apply the migration: |
| 170 | + |
| 171 | +```bash |
| 172 | +uv run alembic upgrade head |
| 173 | +``` |
| 174 | + |
| 175 | +6. Update `scripts/init.py` to include the table in RLS policies if needed. |
| 176 | + |
| 177 | +## Adding an Index |
| 178 | + |
| 179 | +1. Generate a migration: |
| 180 | + |
| 181 | +```bash |
| 182 | +uv run alembic revision -m "Add index on users.email" |
| 183 | +``` |
| 184 | + |
| 185 | +2. Edit the migration: |
| 186 | + |
| 187 | +```python |
| 188 | +def upgrade() -> None: |
| 189 | + op.create_index("idx_users_email", "users", ["email"]) |
| 190 | + |
| 191 | +def downgrade() -> None: |
| 192 | + op.drop_index("idx_users_email", "users") |
| 193 | +``` |
| 194 | + |
| 195 | +3. Apply: |
| 196 | + |
| 197 | +```bash |
| 198 | +uv run alembic upgrade head |
| 199 | +``` |
| 200 | + |
| 201 | +## Production Considerations |
| 202 | + |
| 203 | +### Applying migrations to production |
| 204 | + |
| 205 | +1. Migrations are automatically applied when deploying |
| 206 | +2. Always test migrations locally first |
| 207 | +3. For data migrations, consider running during low-traffic periods |
| 208 | + |
| 209 | +### Transitioning production from old system to Alembic |
| 210 | + |
| 211 | +Production databases that were created before Alembic (using the old `SQLModel.metadata.create_all()` approach or raw Supabase migrations) need special handling. Running `alembic upgrade head` would fail because the tables already exist. |
| 212 | + |
| 213 | +**The solution: `alembic stamp`** |
| 214 | + |
| 215 | +The `alembic stamp` command marks a migration as "already applied" without actually running it. This tells Alembic "the database is already at this state, start tracking from here." |
| 216 | + |
| 217 | +**How it works:** |
| 218 | + |
| 219 | +1. `alembic stamp <revision_id>` inserts a row into the `alembic_version` table with the specified revision ID |
| 220 | +2. Alembic now thinks that migration (and all migrations before it) have been applied |
| 221 | +3. Future migrations will run normally starting from that point |
| 222 | + |
| 223 | +**Step-by-step production transition:** |
| 224 | + |
| 225 | +```bash |
| 226 | +# 1. Connect to production database |
| 227 | +# (set SUPABASE_DB_URL or other connection env vars) |
| 228 | + |
| 229 | +# 2. Check if alembic_version table exists |
| 230 | +# If not, Alembic will create it automatically |
| 231 | + |
| 232 | +# 3. Verify production schema matches the initial migration |
| 233 | +# Compare tables/columns in production against alembic/versions/20260204_d6e30d3b834d_initial_schema.py |
| 234 | + |
| 235 | +# 4. Stamp the initial migration as applied |
| 236 | +uv run alembic stamp d6e30d3b834d |
| 237 | + |
| 238 | +# 5. If production also has the indexes from the second migration, stamp that too |
| 239 | +uv run alembic stamp a17ac554f4aa |
| 240 | + |
| 241 | +# 6. Verify the stamp worked |
| 242 | +uv run alembic current |
| 243 | +# Should show: a17ac554f4aa (head) |
| 244 | + |
| 245 | +# 7. From now on, new migrations will apply normally |
| 246 | +uv run alembic upgrade head |
| 247 | +``` |
| 248 | + |
| 249 | +**Handling partially applied migrations:** |
| 250 | + |
| 251 | +If production has some but not all changes from a migration: |
| 252 | + |
| 253 | +1. Manually apply the missing changes via SQL |
| 254 | +2. Then stamp that migration as complete |
| 255 | +3. Or: create a new migration that only adds the missing pieces |
| 256 | + |
| 257 | +**After stamping:** |
| 258 | + |
| 259 | +- All future schema changes go through Alembic migrations |
| 260 | +- Developers generate migrations with `alembic revision --autogenerate` |
| 261 | +- Deployments run `alembic upgrade head` to apply pending migrations |
| 262 | +- The `alembic_version` table tracks what's been applied |
| 263 | + |
| 264 | +## File Structure |
| 265 | + |
| 266 | +``` |
| 267 | +alembic/ |
| 268 | +├── env.py # Alembic configuration (imports models, sets DB URL) |
| 269 | +├── script.py.mako # Template for new migrations |
| 270 | +├── versions/ # Migration files |
| 271 | +│ ├── 20260204_d6e30d3b834d_initial_schema.py |
| 272 | +│ └── 20260204_a17ac554f4aa_add_parameter_values_indexes.py |
| 273 | +alembic.ini # Alembic settings |
| 274 | +
|
| 275 | +supabase/ |
| 276 | +├── migrations/ # Supabase-specific migrations (storage only) |
| 277 | +│ ├── 20241119000000_storage_bucket.sql |
| 278 | +│ └── 20241121000000_storage_policies.sql |
| 279 | +└── migrations_archived/ # Old table migrations (now in Alembic) |
| 280 | +``` |
| 281 | + |
| 282 | +## Troubleshooting |
| 283 | + |
| 284 | +### "Target database is not up to date" |
| 285 | + |
| 286 | +Run `alembic upgrade head` to apply pending migrations. |
| 287 | + |
| 288 | +### "Can't locate revision" |
| 289 | + |
| 290 | +The alembic_version table has a revision that doesn't exist in your migrations folder. This can happen if someone deleted a migration file. Fix by stamping to a known revision: |
| 291 | + |
| 292 | +```bash |
| 293 | +alembic stamp head # If tables are current |
| 294 | +alembic stamp d6e30d3b834d # If at initial schema |
| 295 | +``` |
| 296 | + |
| 297 | +### "Table already exists" |
| 298 | + |
| 299 | +The migration is trying to create a table that already exists. Options: |
| 300 | +1. If this is a fresh setup, drop and recreate: `uv run python scripts/init.py --reset` |
| 301 | +2. If in production, stamp the migration as applied: `alembic stamp <revision>` |
0 commit comments