6.8 KiB
Database Guidelines
Database patterns and conventions for this project.
Overview
This project uses:
- SQLAlchemy 2.0 with async support (
asyncpgdriver) - Alembic for schema migrations
- Supabase Cloud for Auth, Database, and Storage
- PostgreSQL as primary database (via Supabase connection pooler)
- Soft delete pattern with
deleted_atcolumn
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 IDget_one(*filters)- Get single entity with filtersupdate_by_id(entity_id, update_data)- Update entitysoft_delete_by_id(entity_id)- Soft delete (setsdeleted_at)
Soft delete behavior:
- Queries automatically exclude deleted records
deleted_atis set todatetime.now(timezone.utc)- Repository checks if model has
deleted_atcolumn 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
BaseRepositorymethods or applyself._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:
- Use
supabase mcptools (supabase_execute_sql,supabase_list_tables) to view data - Use Alembic migrations to modify schema
- Use service-role DB access only in backend
- Soft delete uses
deleted_at; reads exclude deleted by default