2026-04-10 16:45:45 +08:00
# 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
2026-04-27 10:04:29 +08:00
- **Supabase Cloud** for Auth, Database, and Storage
- **PostgreSQL** as primary database (via Supabase connection pooler)
2026-04-10 16:45:45 +08:00
- **Soft delete** pattern with `deleted_at` column
2026-04-27 10:04:29 +08:00
### 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.<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.
2026-04-10 16:45:45 +08:00
---
## 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
```
2026-04-27 10:04:29 +08:00
**Note: ** Cloud Supabase migrations may be slower than local due to network latency. Use appropriate timeouts for migration scripts.
2026-04-10 16:45:45 +08:00
**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