Files
eryao/.trellis/spec/backend/database-guidelines.md
T

6.1 KiB

Database Guidelines

Database patterns and conventions for this project.


Overview

This project uses:

  • SQLAlchemy 2.0 with async support (asyncpg driver)
  • Alembic for schema migrations
  • Supabase Auth as identity source
  • PostgreSQL as primary database
  • Soft delete pattern with deleted_at column

Query Patterns

Base Repository Pattern

All repositories inherit from BaseRepository (core.db.base_repository.BaseRepository):

from core.db.base_repository import BaseRepository
from models.agent_chat_session import AgentChatSession

class AgentRepository(BaseRepository[AgentChatSession]):
    def __init__(self, session: AsyncSession) -> None:
        super().__init__(session, AgentChatSession)

Provided methods:

  • get_by_id(entity_id) - Get single entity by ID
  • get_one(*filters) - Get single entity with filters
  • update_by_id(entity_id, update_data) - Update entity
  • soft_delete_by_id(entity_id) - Soft delete (sets deleted_at)

Soft delete behavior:

  • Queries automatically exclude deleted records
  • deleted_at is set to datetime.now(timezone.utc)
  • Repository checks if model has deleted_at column before applying filter

Query Examples

# Get by ID (auto-filters deleted)
session = await repository.get_by_id(session_id)

# Get with custom filters
session = await repository.get_one(
    AgentChatSession.owner_id == user_id,
    AgentChatSession.id == session_id,
)

# Update entity
updated = await repository.update_by_id(
    session_id,
    {"title": "New Title", "updated_at": datetime.now(timezone.utc)},
)

Custom Queries

For complex queries, add custom methods to repository:

class AgentRepository(BaseRepository[AgentChatSession]):
    async def get_active_sessions_by_user(
        self, user_id: UUID
    ) -> list[AgentChatSession]:
        stmt = (
            select(self._model)
            .where(self._model.owner_id == user_id)
            .order_by(self._model.created_at.desc())
        )
        stmt = self._apply_soft_delete_filter(stmt)
        result = await self._session.execute(stmt)
        return list(result.scalars().all())

Migrations

Creating Migrations

Use dev-migrate.sh script:

# Create new migration
./infra/scripts/dev-migrate.sh revision --autogenerate -m "add user preferences table"

Migration file naming:

  • Format: YYYYMMDD_####_<description>.py
  • Example: 20260411_0001_initial_llm_schema.py

Migration structure:

def upgrade() -> None:
    op.create_table(
        "llm_factory",
        sa.Column("id", sa.UUID(), nullable=False),
        sa.Column("name", sa.String(length=50), nullable=False),
        sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
        sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
        sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
        sa.PrimaryKeyConstraint("id"),
        sa.UniqueConstraint("name"),
    )
    op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True)
    _enable_rls("llm_factory")  # RLS for Supabase Auth

def downgrade() -> None:
    op.drop_index("ix_llm_factory_name", table_name="llm_factory")
    op.drop_table("llm_factory")

Running Migrations

Three modes (via dev-migrate.sh):

# Migrations only
./infra/scripts/dev-migrate.sh migrate

# Seed data only
./infra/scripts/dev-migrate.sh init-data

# Both (migrate + seed)
./infra/scripts/dev-migrate.sh bootstrap

Alembic is the ONLY source of truth for schema changes.


Naming Conventions

Tables

  • snake_case: agent_chat_session, llm_factory, points_ledger
  • Plural or singular: Based on domain semantics
  • Timestamps: created_at, updated_at, deleted_at (always timezone-aware)

Columns

  • snake_case: owner_id, model_code, request_url
  • Foreign keys: <entity>_id (e.g., factory_id, owner_id)
  • Indexes: ix_<table>_<column> (e.g., ix_llms_model_code)

Models

  • PascalCase: AgentChatSession, LlmFactory
  • File location: models/<entity>.py

Transactions

Service Layer Responsibility

Services own transaction boundaries:

class AgentService:
    async def enqueue_run(self, ...) -> TaskAccepted:
        async with self._repository._session.begin():  # Transaction start
            # Multiple repository calls
            session = await self._repository.get_by_id(session_id)
            await self._repository.update_by_id(session_id, {...})
            # Transaction commit on successful exit

Repository Layer

Repositories do NOT manage transactions:

# Repository uses session, but doesn't commit
result = await self._session.execute(stmt)
await self._session.flush()  # Flush to DB, but don't commit
return result.scalar_one_or_none()

Common Mistakes

Using raw SQL without Alembic

  • Wrong: Creating tables directly in code
  • Right: All DDL changes via Alembic migrations

Hardcoding database credentials

  • Wrong: password = "postgres123"
  • Right: Use core.config.settings.Settings.database.password

Bypassing soft delete filter

  • Wrong: select(Model).where(Model.id == x) (includes deleted)
  • Right: Use BaseRepository methods or apply self._apply_soft_delete_filter()

Using session.commit() in repository

  • Wrong: Repository calling await session.commit()
  • Right: Let service layer manage transactions with session.begin()

Not using timezone-aware timestamps

  • Wrong: datetime.now()
  • Right: datetime.now(timezone.utc)

Getting owner_id from client payload

  • Wrong: owner_id = payload.owner_id
  • Right: owner_id = current_user.id (from verified JWT sub claim)

Database Access Rules

From AGENTS.md:

  1. Use supabase mcp tools (supabase_execute_sql, supabase_list_tables) to view data
  2. Use Alembic migrations to modify schema
  3. Use service-role DB access only in backend
  4. Soft delete uses deleted_at; reads exclude deleted by default