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

6.8 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 Cloud for Auth, Database, and Storage
  • PostgreSQL as primary database (via Supabase connection pooler)
  • Soft delete pattern with deleted_at column

Cloud Supabase Connection

The project uses Supabase Cloud with connection pooling:

# .env configuration
ERYAO_DATABASE__HOST=aws-1-us-east-2.pooler.supabase.com
ERYAO_DATABASE__PORT=5432  # Session pooler (IPv4 compatible)
ERYAO_DATABASE__NAME=postgres
ERYAO_DATABASE__USER=postgres.<project-ref>
ERYAO_DATABASE__PASSWORD=<your-password>

Note: Direct database connection (port 5432 on db.<project-ref>.supabase.co) requires IPv6 and is not suitable for most development environments. Use the connection pooler instead.


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

Note: Cloud Supabase migrations may be slower than local due to network latency. Use appropriate timeouts for migration scripts.

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