221 lines
6.1 KiB
Markdown
221 lines
6.1 KiB
Markdown
|
|
# 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
|