# 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