mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 11:04:36 +00:00
698 lines
15 KiB
Markdown
698 lines
15 KiB
Markdown
# Database Guide
|
|
|
|
This guide covers database design, migrations, and best practices for Routstr Core.
|
|
|
|
## Database Overview
|
|
|
|
Routstr uses:
|
|
- **SQLite** for local development and single-node deployments
|
|
- **PostgreSQL** (optional) for production scale
|
|
- **SQLModel** for ORM with type safety
|
|
- **Alembic** for schema migrations
|
|
- **Async SQLAlchemy** for non-blocking I/O
|
|
|
|
## Schema Design
|
|
|
|
### Core Tables
|
|
|
|
#### APIKey Table
|
|
|
|
```python
|
|
class APIKey(SQLModel, table=True):
|
|
"""API key with balance tracking"""
|
|
|
|
# Primary key
|
|
id: int | None = Field(default=None, primary_key=True)
|
|
|
|
# Key data (indexed for fast lookups)
|
|
key_hash: str = Field(index=True, unique=True)
|
|
|
|
# Balance in millisatoshis (1 sat = 1000 msats)
|
|
balance: int = Field(default=0)
|
|
total_deposited: int = Field(default=0)
|
|
total_spent: int = Field(default=0)
|
|
|
|
# Timestamps
|
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
last_used_at: datetime | None = None
|
|
expires_at: datetime | None = None
|
|
|
|
# Metadata (JSON field)
|
|
metadata: dict = Field(
|
|
default_factory=dict,
|
|
sa_column=Column(JSON)
|
|
)
|
|
|
|
# Relationships
|
|
transactions: list["Transaction"] = Relationship(back_populates="api_key")
|
|
```
|
|
|
|
#### Transaction Table
|
|
|
|
```python
|
|
class Transaction(SQLModel, table=True):
|
|
"""Transaction log for audit trail"""
|
|
|
|
# Primary key
|
|
id: int | None = Field(default=None, primary_key=True)
|
|
|
|
# Foreign key to API key
|
|
api_key_id: int = Field(foreign_key="apikey.id", index=True)
|
|
|
|
# Transaction details
|
|
amount: int # Can be negative for deductions
|
|
balance_after: int # Balance snapshot
|
|
type: TransactionType # Enum: deposit, usage, withdrawal
|
|
description: str
|
|
|
|
# Timestamp (indexed for range queries)
|
|
timestamp: datetime = Field(
|
|
default_factory=datetime.utcnow,
|
|
index=True
|
|
)
|
|
|
|
# Request details (optional)
|
|
request_data: dict | None = Field(
|
|
default=None,
|
|
sa_column=Column(JSON)
|
|
)
|
|
|
|
# Relationships
|
|
api_key: APIKey = Relationship(back_populates="transactions")
|
|
```
|
|
|
|
#### Withdrawal Table
|
|
|
|
```python
|
|
class Withdrawal(SQLModel, table=True):
|
|
"""Track withdrawal requests"""
|
|
|
|
id: int | None = Field(default=None, primary_key=True)
|
|
api_key_id: int = Field(foreign_key="apikey.id")
|
|
|
|
# Withdrawal details
|
|
amount: int
|
|
token: str # Encrypted eCash token
|
|
mint_url: str
|
|
|
|
# Status tracking
|
|
status: WithdrawalStatus # pending, completed, failed
|
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
completed_at: datetime | None = None
|
|
|
|
# Error handling
|
|
error_message: str | None = None
|
|
retry_count: int = Field(default=0)
|
|
```
|
|
|
|
### Indexes
|
|
|
|
Critical indexes for performance:
|
|
|
|
```python
|
|
# In models
|
|
key_hash: str = Field(index=True, unique=True) # Fast key lookup
|
|
timestamp: datetime = Field(index=True) # Range queries
|
|
|
|
# Composite indexes (in migrations)
|
|
Index('idx_transactions_key_time', 'api_key_id', 'timestamp')
|
|
Index('idx_apikey_expires', 'expires_at').where(expires_at.isnot(None))
|
|
```
|
|
|
|
## Migrations
|
|
|
|
### Creating Migrations
|
|
|
|
#### Auto-generate from Model Changes
|
|
|
|
```bash
|
|
# After modifying SQLModel classes
|
|
make db-migrate
|
|
|
|
# Enter descriptive message
|
|
> Add withdrawal status field
|
|
```
|
|
|
|
#### Manual Migration
|
|
|
|
```bash
|
|
# Create empty migration
|
|
alembic revision -m "custom migration"
|
|
|
|
# Edit the generated file
|
|
```
|
|
|
|
#### Migration Template
|
|
|
|
```python
|
|
"""Add withdrawal status field
|
|
|
|
Revision ID: abc123
|
|
Revises: def456
|
|
Create Date: 2024-01-01 12:00:00
|
|
|
|
"""
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
import sqlmodel
|
|
|
|
# revision identifiers
|
|
revision = 'abc123'
|
|
down_revision = 'def456'
|
|
|
|
def upgrade() -> None:
|
|
"""Apply migration"""
|
|
op.add_column(
|
|
'withdrawal',
|
|
sa.Column(
|
|
'status',
|
|
sa.String(),
|
|
nullable=False,
|
|
server_default='pending'
|
|
)
|
|
)
|
|
|
|
# Add index
|
|
op.create_index(
|
|
'idx_withdrawal_status',
|
|
'withdrawal',
|
|
['status']
|
|
)
|
|
|
|
def downgrade() -> None:
|
|
"""Revert migration"""
|
|
op.drop_index('idx_withdrawal_status', 'withdrawal')
|
|
op.drop_column('withdrawal', 'status')
|
|
```
|
|
|
|
### Running Migrations
|
|
|
|
#### Development
|
|
|
|
```bash
|
|
# Apply all migrations
|
|
make db-upgrade
|
|
|
|
# Check current version
|
|
make db-current
|
|
|
|
# Rollback one version
|
|
make db-downgrade
|
|
|
|
# View history
|
|
make db-history
|
|
```
|
|
|
|
#### Production
|
|
|
|
Migrations run automatically on startup:
|
|
|
|
```python
|
|
# In routstr/core/main.py
|
|
def run_migrations():
|
|
"""Run database migrations on startup"""
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
|
|
alembic_cfg = Config("alembic.ini")
|
|
command.upgrade(alembic_cfg, "head")
|
|
```
|
|
|
|
### Migration Best Practices
|
|
|
|
1. **Always Review Generated Migrations**
|
|
- Check for data loss
|
|
- Verify index creation
|
|
- Test rollback
|
|
|
|
2. **Handle Data Migrations**
|
|
```python
|
|
def upgrade():
|
|
# Schema change
|
|
op.add_column('apikey', sa.Column('status', sa.String()))
|
|
|
|
# Data migration
|
|
connection = op.get_bind()
|
|
connection.execute(
|
|
"UPDATE apikey SET status = 'active' WHERE expires_at IS NULL"
|
|
)
|
|
```
|
|
|
|
3. **Make Migrations Idempotent**
|
|
```python
|
|
def upgrade():
|
|
# Check if column exists
|
|
inspector = sa.inspect(op.get_bind())
|
|
columns = [col['name'] for col in inspector.get_columns('apikey')]
|
|
|
|
if 'new_field' not in columns:
|
|
op.add_column('apikey', sa.Column('new_field', sa.String()))
|
|
```
|
|
|
|
## Database Operations
|
|
|
|
### Connection Management
|
|
|
|
```python
|
|
# Database session factory
|
|
async_session = sessionmaker(
|
|
engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False
|
|
)
|
|
|
|
# Dependency injection
|
|
async def get_db() -> AsyncSession:
|
|
async with async_session() as session:
|
|
yield session
|
|
```
|
|
|
|
### Query Patterns
|
|
|
|
#### Basic Queries
|
|
|
|
```python
|
|
# Get by primary key
|
|
api_key = await session.get(APIKey, key_id)
|
|
|
|
# Get by unique field
|
|
result = await session.execute(
|
|
select(APIKey).where(APIKey.key_hash == hash_value)
|
|
)
|
|
api_key = result.scalar_one_or_none()
|
|
|
|
# Get multiple with filter
|
|
result = await session.execute(
|
|
select(APIKey)
|
|
.where(APIKey.balance > 0)
|
|
.where(APIKey.expires_at > datetime.utcnow())
|
|
)
|
|
active_keys = result.scalars().all()
|
|
```
|
|
|
|
#### Joins and Relationships
|
|
|
|
```python
|
|
# Eager loading
|
|
result = await session.execute(
|
|
select(APIKey)
|
|
.options(selectinload(APIKey.transactions))
|
|
.where(APIKey.id == key_id)
|
|
)
|
|
api_key = result.scalar_one()
|
|
|
|
# Join query
|
|
result = await session.execute(
|
|
select(Transaction)
|
|
.join(APIKey)
|
|
.where(APIKey.key_hash == hash_value)
|
|
.order_by(Transaction.timestamp.desc())
|
|
.limit(10)
|
|
)
|
|
recent_transactions = result.scalars().all()
|
|
```
|
|
|
|
#### Aggregations
|
|
|
|
```python
|
|
# Sum total spent
|
|
result = await session.execute(
|
|
select(func.sum(Transaction.amount))
|
|
.where(Transaction.api_key_id == key_id)
|
|
.where(Transaction.type == TransactionType.USAGE)
|
|
)
|
|
total_spent = result.scalar() or 0
|
|
|
|
# Count active keys
|
|
result = await session.execute(
|
|
select(func.count(APIKey.id))
|
|
.where(APIKey.balance > 0)
|
|
)
|
|
active_count = result.scalar()
|
|
```
|
|
|
|
### Transactions
|
|
|
|
#### Atomic Operations
|
|
|
|
```python
|
|
async def transfer_balance(
|
|
session: AsyncSession,
|
|
from_key: int,
|
|
to_key: int,
|
|
amount: int
|
|
):
|
|
"""Atomic balance transfer"""
|
|
async with session.begin():
|
|
# Lock rows to prevent race conditions
|
|
from_api_key = await session.execute(
|
|
select(APIKey)
|
|
.where(APIKey.id == from_key)
|
|
.with_for_update()
|
|
)
|
|
from_api_key = from_api_key.scalar_one()
|
|
|
|
to_api_key = await session.execute(
|
|
select(APIKey)
|
|
.where(APIKey.id == to_key)
|
|
.with_for_update()
|
|
)
|
|
to_api_key = to_api_key.scalar_one()
|
|
|
|
# Check balance
|
|
if from_api_key.balance < amount:
|
|
raise InsufficientBalanceError()
|
|
|
|
# Update balances
|
|
from_api_key.balance -= amount
|
|
to_api_key.balance += amount
|
|
|
|
# Log transactions
|
|
session.add(Transaction(
|
|
api_key_id=from_key,
|
|
amount=-amount,
|
|
balance_after=from_api_key.balance,
|
|
type=TransactionType.TRANSFER_OUT
|
|
))
|
|
|
|
session.add(Transaction(
|
|
api_key_id=to_key,
|
|
amount=amount,
|
|
balance_after=to_api_key.balance,
|
|
type=TransactionType.TRANSFER_IN
|
|
))
|
|
|
|
# Commit happens automatically
|
|
```
|
|
|
|
#### Optimistic Locking
|
|
|
|
```python
|
|
class APIKey(SQLModel, table=True):
|
|
# Add version field
|
|
version: int = Field(default=1)
|
|
|
|
async def update_with_version_check(
|
|
session: AsyncSession,
|
|
api_key: APIKey,
|
|
new_balance: int
|
|
):
|
|
"""Update with optimistic locking"""
|
|
result = await session.execute(
|
|
update(APIKey)
|
|
.where(APIKey.id == api_key.id)
|
|
.where(APIKey.version == api_key.version)
|
|
.values(
|
|
balance=new_balance,
|
|
version=APIKey.version + 1
|
|
)
|
|
)
|
|
|
|
if result.rowcount == 0:
|
|
raise ConcurrentModificationError()
|
|
```
|
|
|
|
## Performance Optimization
|
|
|
|
### Query Optimization
|
|
|
|
1. **Use Indexes Effectively**
|
|
```python
|
|
# Good: Uses index
|
|
where(APIKey.key_hash == value)
|
|
|
|
# Bad: Function prevents index use
|
|
where(func.lower(APIKey.key_hash) == value.lower())
|
|
```
|
|
|
|
2. **Limit Results**
|
|
```python
|
|
# Always limit when possible
|
|
query.limit(100)
|
|
|
|
# Use pagination
|
|
query.offset(page * page_size).limit(page_size)
|
|
```
|
|
|
|
3. **Select Only Needed Columns**
|
|
```python
|
|
# Select specific columns
|
|
result = await session.execute(
|
|
select(APIKey.id, APIKey.balance)
|
|
.where(APIKey.key_hash == hash_value)
|
|
)
|
|
```
|
|
|
|
### Connection Pooling
|
|
|
|
```python
|
|
# Configure connection pool
|
|
engine = create_async_engine(
|
|
DATABASE_URL,
|
|
pool_size=20, # Number of connections
|
|
max_overflow=10, # Extra connections when needed
|
|
pool_timeout=30, # Wait time for connection
|
|
pool_recycle=3600, # Recycle connections after 1 hour
|
|
pool_pre_ping=True, # Check connection health
|
|
)
|
|
```
|
|
|
|
### Batch Operations
|
|
|
|
```python
|
|
# Batch insert
|
|
async def bulk_create_transactions(
|
|
session: AsyncSession,
|
|
transactions: list[dict]
|
|
):
|
|
"""Efficient bulk insert"""
|
|
await session.execute(
|
|
insert(Transaction),
|
|
transactions
|
|
)
|
|
await session.commit()
|
|
|
|
# Batch update
|
|
await session.execute(
|
|
update(APIKey)
|
|
.where(APIKey.expires_at < datetime.utcnow())
|
|
.values(active=False)
|
|
)
|
|
```
|
|
|
|
## Testing Database Code
|
|
|
|
### Test Database Setup
|
|
|
|
```python
|
|
@pytest.fixture
|
|
async def test_engine():
|
|
"""Create test database engine"""
|
|
engine = create_async_engine(
|
|
"sqlite+aiosqlite:///:memory:",
|
|
echo=True # Log SQL for debugging
|
|
)
|
|
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(SQLModel.metadata.create_all)
|
|
|
|
yield engine
|
|
|
|
await engine.dispose()
|
|
```
|
|
|
|
### Testing Queries
|
|
|
|
```python
|
|
async def test_balance_update(test_session):
|
|
"""Test atomic balance update"""
|
|
# Create test data
|
|
api_key = APIKey(key_hash="test", balance=1000)
|
|
test_session.add(api_key)
|
|
await test_session.commit()
|
|
|
|
# Test update
|
|
await deduct_balance(test_session, api_key.id, 100)
|
|
|
|
# Verify
|
|
await test_session.refresh(api_key)
|
|
assert api_key.balance == 900
|
|
|
|
# Verify transaction log
|
|
result = await test_session.execute(
|
|
select(Transaction)
|
|
.where(Transaction.api_key_id == api_key.id)
|
|
)
|
|
transactions = result.scalars().all()
|
|
assert len(transactions) == 1
|
|
assert transactions[0].amount == -100
|
|
```
|
|
|
|
### Testing Migrations
|
|
|
|
```python
|
|
def test_migration_upgrade():
|
|
"""Test migration applies correctly"""
|
|
# Create database at previous version
|
|
alembic_cfg = Config("alembic.ini")
|
|
command.downgrade(alembic_cfg, "-1")
|
|
|
|
# Apply migration
|
|
command.upgrade(alembic_cfg, "+1")
|
|
|
|
# Verify schema changes
|
|
engine = create_engine(DATABASE_URL)
|
|
inspector = inspect(engine)
|
|
columns = [col['name'] for col in inspector.get_columns('apikey')]
|
|
assert 'new_column' in columns
|
|
```
|
|
|
|
## Database Maintenance
|
|
|
|
### Monitoring Queries
|
|
|
|
```sql
|
|
-- Slow queries (SQLite)
|
|
EXPLAIN QUERY PLAN
|
|
SELECT * FROM apikey WHERE balance > 0;
|
|
|
|
-- Table sizes
|
|
SELECT
|
|
name,
|
|
COUNT(*) as row_count
|
|
FROM sqlite_master
|
|
WHERE type='table'
|
|
GROUP BY name;
|
|
|
|
-- Index usage
|
|
SELECT * FROM sqlite_stat1;
|
|
```
|
|
|
|
### Cleanup Tasks
|
|
|
|
```python
|
|
async def cleanup_expired_keys(session: AsyncSession):
|
|
"""Remove expired API keys"""
|
|
result = await session.execute(
|
|
delete(APIKey)
|
|
.where(APIKey.expires_at < datetime.utcnow())
|
|
.where(APIKey.balance == 0)
|
|
)
|
|
|
|
logger.info(f"Cleaned up {result.rowcount} expired keys")
|
|
await session.commit()
|
|
|
|
async def vacuum_database(session: AsyncSession):
|
|
"""Optimize database file size (SQLite)"""
|
|
await session.execute(text("VACUUM"))
|
|
```
|
|
|
|
### Backup Strategies
|
|
|
|
```python
|
|
async def backup_database(source_url: str, backup_path: str):
|
|
"""Create database backup"""
|
|
if "sqlite" in source_url:
|
|
# SQLite backup
|
|
import shutil
|
|
db_path = source_url.split("///")[1]
|
|
shutil.copy2(db_path, backup_path)
|
|
else:
|
|
# PostgreSQL backup
|
|
import subprocess
|
|
subprocess.run([
|
|
"pg_dump",
|
|
source_url,
|
|
"-f", backup_path
|
|
])
|
|
```
|
|
|
|
## PostgreSQL Migration
|
|
|
|
### Configuration
|
|
|
|
```python
|
|
# PostgreSQL connection
|
|
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/routstr"
|
|
|
|
# Additional PostgreSQL-specific settings
|
|
engine = create_async_engine(
|
|
DATABASE_URL,
|
|
server_settings={
|
|
"jit": "off",
|
|
"statement_timeout": "30s"
|
|
}
|
|
)
|
|
```
|
|
|
|
### PostgreSQL-Specific Features
|
|
|
|
```python
|
|
# Use PostgreSQL arrays
|
|
from sqlalchemy.dialects.postgresql import ARRAY
|
|
|
|
class APIKey(SQLModel, table=True):
|
|
allowed_models: list[str] = Field(
|
|
default_factory=list,
|
|
sa_column=Column(ARRAY(String))
|
|
)
|
|
|
|
# Use JSONB for better performance
|
|
metadata: dict = Field(
|
|
default_factory=dict,
|
|
sa_column=Column(JSONB)
|
|
)
|
|
|
|
# Full-text search
|
|
from sqlalchemy.dialects.postgresql import TSVECTOR
|
|
|
|
search_vector = Column(TSVECTOR)
|
|
```
|
|
|
|
## Security Considerations
|
|
|
|
### SQL Injection Prevention
|
|
|
|
```python
|
|
# Always use parameterized queries
|
|
# Good
|
|
await session.execute(
|
|
select(APIKey).where(APIKey.key_hash == key_hash)
|
|
)
|
|
|
|
# Bad - SQL injection risk
|
|
await session.execute(
|
|
text(f"SELECT * FROM apikey WHERE key_hash = '{key_hash}'")
|
|
)
|
|
```
|
|
|
|
### Data Encryption
|
|
|
|
```python
|
|
from cryptography.fernet import Fernet
|
|
|
|
class EncryptedField(TypeDecorator):
|
|
"""Encrypt sensitive data at rest"""
|
|
impl = String
|
|
|
|
def __init__(self, key: bytes, *args, **kwargs):
|
|
self.cipher = Fernet(key)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def process_bind_param(self, value, dialect):
|
|
if value is not None:
|
|
return self.cipher.encrypt(value.encode()).decode()
|
|
return value
|
|
|
|
def process_result_value(self, value, dialect):
|
|
if value is not None:
|
|
return self.cipher.decrypt(value.encode()).decode()
|
|
return value
|
|
```
|
|
|
|
## Next Steps
|
|
|
|
- Review [Testing Guide](testing.md) for database testing
|
|
- Check [Guidelines](guidelines.md) for code standards
|
|
- See [Architecture](architecture.md) for system design
|
|
- Read [Setup Guide](setup.md) for development setup |