6.1 KiB
6.1 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 Auth as identity source
- PostgreSQL as primary database
- Soft delete pattern with
deleted_atcolumn
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
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