# 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: ```bash # .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. ERYAO_DATABASE__PASSWORD= ``` **Note:** Direct database connection (port 5432 on `db..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`): ```python 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 ```python # 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: ```python 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:** ```bash # Create new migration ./infra/scripts/dev-migrate.sh revision --autogenerate -m "add user preferences table" ``` **Migration file naming:** - Format: `YYYYMMDD_####_.py` - Example: `20260411_0001_initial_llm_schema.py` **Migration structure:** ```python 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`):** ```bash # 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**: `_id` (e.g., `factory_id`, `owner_id`) - **Indexes**: `ix__` (e.g., `ix_llms_model_code`) ### Models - **PascalCase**: `AgentChatSession`, `LlmFactory` - **File location**: `models/.py` --- ## Transactions ### Service Layer Responsibility **Services own transaction boundaries:** ```python 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:** ```python # 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