Skip to main content

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:

  1. Maintaining authoritative SQLAlchemy models in code (boards.dbmodels)
  2. Autogenerating Alembic revisions from model diffs
  3. Writing explicit revision scripts for non-ORM objects (extensions, functions, triggers, RLS)
  4. 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_convention in dbmodels to 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.