Database Migrations
Boards uses Alembic with async engines and timestamped filenames. ORM models in boards.dbmodels are the source of truth.
Migration Philosophy
We avoid schema drift by:
- Maintaining authoritative SQLAlchemy models in code (
boards.dbmodels) - Autogenerating Alembic revisions from model diffs
- Writing explicit revision scripts for non-ORM objects (extensions, functions, triggers, RLS)
- Requiring reversible
downgrade()where feasible; document exceptions
Quick Workflow
# 1) Make model changes in src/boards/dbmodels/
# 2) Autogenerate a new revision
uv run alembic revision -m "add user preferences" --autogenerate
# 3) Review and edit the generated revision file under alembic/versions/
# 4) Apply the migration
uv run alembic upgrade head
# 5) Rollback if needed
uv run alembic downgrade -1
Timestamped Filenames
Alembic is configured to generate filenames like:
20250901_120301_add_user_preferences.py
This improves readability and sort order in version control.
Initial Setup
On a new environment:
cd packages/backend
uv run alembic upgrade head
This creates all tables and enables required extensions (e.g., uuid-ossp).
Managing Non-ORM Objects
Use explicit SQL in revisions for extensions, triggers, functions, and policies.
Example (RLS policy):
from alembic import op
def upgrade() -> None:
op.execute("ALTER TABLE users ENABLE ROW LEVEL SECURITY;")
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'public' AND tablename = 'users' AND policyname = 'users_isolation'
) THEN
CREATE POLICY users_isolation ON users
USING (tenant_id = current_setting('app.tenant_id')::uuid);
END IF;
END$$;
"""
)
def downgrade() -> None:
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'public' AND tablename = 'users' AND policyname = 'users_isolation'
) THEN
DROP POLICY users_isolation ON users;
END IF;
END$$;
"""
)
op.execute("ALTER TABLE users DISABLE ROW LEVEL SECURITY;")
Counter-example (avoid):
# Missing guards, non-reversible
op.execute("ALTER TABLE users ENABLE ROW LEVEL SECURITY;")
op.execute("CREATE POLICY users_isolation ON users USING (...);")
def downgrade():
pass
Data Migrations
Write data backfills inside revisions. Prefer reversible operations when feasible; otherwise, document irreversibility.
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column
users = table('users', column('id', sa.String), column('display_name', sa.String))
def upgrade() -> None:
conn = op.get_bind()
conn.execute(users.update().where(users.c.display_name == None).values(display_name=''))
def downgrade() -> None:
# Best-effort reversal if needed
pass
Autogenerate Tips
- Keep
naming_conventionindbmodelsto produce stable diffs - Use
compare_type=True(enabled) to detect type changes - Review generated diffs carefully—add indexes/constraints where required
Common Commands
# New revision (blank)
uv run alembic revision -m "empty revision"
# New revision from model changes
uv run alembic revision -m "add columns" --autogenerate
# Upgrade / downgrade
uv run alembic upgrade head
uv run alembic downgrade base
# Show history
uv run alembic history --verbose
CI Enforcement
Recommended checks:
uv run alembic upgrade head && uv run alembic downgrade base- Lint new revisions to ensure
downgrade()is present and non-empty
File Structure
packages/backend/
├── alembic/
│ ├── env.py
│ └── versions/
├── alembic.ini
└── src/boards/dbmodels/
The boards.database.models module re-exports from boards.dbmodels for compatibility, but new imports should use boards.dbmodels directly.