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

221 lines
6.1 KiB
Markdown
Raw Normal View History

# 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`):
```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_####_<description>.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
```
**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:**
```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