Merge pull request #29 from Routstr/codex/add-alembic-migrations-for-sqlmodel

Add Alembic migrations
This commit is contained in:
shroominic
2025-08-09 13:55:21 -03:00
committed by GitHub
12 changed files with 1710 additions and 1339 deletions
+49 -1
View File
@@ -7,14 +7,16 @@ ifeq ($(VENV_EXISTS), 1)
PYTEST := .venv/bin/pytest
RUFF := .venv/bin/ruff
MYPY := .venv/bin/mypy
ALEMBIC := .venv/bin/alembic
else
PYTHON := python
PYTEST := pytest
RUFF := ruff
MYPY := mypy
ALEMBIC := alembic
endif
.PHONY: help setup test test-unit test-integration test-integration-docker test-all test-fast test-performance clean docker-up docker-down lint format type-check dev-setup check-deps
.PHONY: help setup test test-unit test-integration test-integration-docker test-all test-fast test-performance clean docker-up docker-down lint format type-check dev-setup check-deps db-upgrade db-downgrade db-current db-history db-migrate db-revision db-heads db-clean
# Default target
help:
@@ -35,6 +37,11 @@ help:
@echo " make dev-setup - Set up development environment"
@echo " make check-deps - Check system dependencies"
@echo " make setup - First-time project setup"
@echo ""
@echo "Database migration shortcuts:"
@echo " make create-migration - Auto-generate new migration"
@echo " make db-upgrade - Apply all pending migrations"
@echo " make db-downgrade - Downgrade one migration"
# First-time setup
setup: check-deps dev-setup
@@ -133,6 +140,7 @@ check-deps:
@printf " %-18s" "pytest:"; if $(PYTEST) --version >/dev/null 2>&1; then $(PYTEST) --version | head -1; else echo "❌ Not found - run 'make dev-setup'"; fi
@printf " %-18s" "ruff:"; if $(RUFF) --version >/dev/null 2>&1; then $(RUFF) --version; else echo "❌ Not found - run 'make dev-setup'"; fi
@printf " %-18s" "mypy:"; if $(MYPY) --version >/dev/null 2>&1; then $(MYPY) --version; else echo "❌ Not found - run 'make dev-setup'"; fi
@printf " %-18s" "alembic:"; if $(ALEMBIC) --version >/dev/null 2>&1; then $(ALEMBIC) --version; else echo "❌ Not found - run 'make dev-setup'"; fi
@echo ""
@echo "Virtual environment:"
@if [ -d ".venv" ]; then \
@@ -158,6 +166,46 @@ clean:
rm -rf *.egg-info
@echo "✨ Cleanup complete!"
# Database migration management
db-upgrade:
@echo "⬆️ Applying all pending migrations..."
$(ALEMBIC) upgrade head
@echo "✅ Database upgraded to latest revision"
db-downgrade:
@echo "⬇️ Downgrading one migration..."
$(ALEMBIC) downgrade -1
@echo "✅ Database downgraded by one revision"
db-current:
@echo "📍 Current database revision:"
$(ALEMBIC) current -v
db-history:
@echo "📜 Migration history:"
$(ALEMBIC) history --verbose
db-migrate:
@echo "🔍 Auto-generating migration from model changes..."
@read -p "Enter migration message: " msg; \
$(ALEMBIC) revision --autogenerate -m "$$msg"
@echo "✅ Migration generated. Review and edit if needed."
db-revision:
@echo "📝 Creating empty migration file..."
@read -p "Enter migration message: " msg; \
$(ALEMBIC) revision -m "$$msg"
@echo "✅ Empty migration created"
db-heads:
@echo "🎯 Current migration heads:"
$(ALEMBIC) heads
db-clean:
@echo "🧹 Cleaning migration cache files..."
find migrations/ -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
@echo "✅ Migration cache cleaned"
# Advanced testing options
test-coverage:
@echo "📊 Running tests with coverage..."
+43
View File
@@ -97,6 +97,49 @@ The most common settings are shown below. See `.env.example` for the full list.
- `HTTP_URL` Public-facing URL of the proxy
- `ONION_URL` Tor hidden service URL of the proxy
## Database Migrations
The application uses Alembic for database schema management and **automatically runs migrations on startup**. This ensures your database is always up-to-date when deploying new versions.
### Automatic Migrations in Production
When the FastAPI application starts, it automatically:
1. Runs all pending database migrations
2. Updates the schema to the latest version
3. Logs the migration status
This means you don't need to manually run migrations when deploying - just restart the application and migrations will be applied automatically.
### Manual Migration Commands
For development or troubleshooting, you can use these Makefile commands:
```bash
make db-upgrade # Apply all pending migrations
make db-downgrade # Downgrade one migration
make db-current # Show current migration revision
make db-history # Show migration history
make db-migrate # Auto-generate new migration from model changes
make db-revision # Create empty migration file
make db-heads # Show current migration heads
make db-clean # Clean migration cache files
```
### Creating New Migrations
When you modify SQLModel models:
```bash
# Auto-generate a migration from model changes
make db-migrate
# Enter a descriptive message when prompted
# Review the generated migration file in migrations/versions/
# Edit if needed, then test with:
make db-upgrade
```
## Withdrawing Balance
Go to `https://<your.routstr.proxy>/admin/` (NOTE: be sure to add the '/' at the end), enter the `ADMIN_PASSWORD` you set above and withdraw your balance as a Cashu token.
+35
View File
@@ -0,0 +1,35 @@
[alembic]
script_location = migrations
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+70
View File
@@ -0,0 +1,70 @@
import asyncio
import pathlib
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel
# Add the parent directory to the Python path so we can import router modules
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from router.core.db import DATABASE_URL
config = context.config
if config.config_file_name is None:
raise ValueError("config_file_name is None")
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", DATABASE_URL)
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
context.configure(
url=DATABASE_URL,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection, target_metadata=target_metadata, compare_type=True
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = create_async_engine(DATABASE_URL, poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
# Check if we're already in an event loop (e.g., being called from FastAPI)
try:
loop = asyncio.get_running_loop()
# If we're in an existing loop, create a new thread to run migrations
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, run_migrations_online())
future.result()
except RuntimeError:
# No event loop running, we can use asyncio.run directly
asyncio.run(run_migrations_online())
+24
View File
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,31 @@
"""add mint field
Revision ID: 7bc4e8b02b9d
Revises: f6ce1348e266
Create Date: 2025-08-09 13:48:40.648729
"""
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision = "7bc4e8b02b9d"
down_revision = "f6ce1348e266"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"api_keys",
sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("api_keys", "mint_url")
# ### end Alembic commands ###
View File
+37
View File
@@ -0,0 +1,37 @@
"""init
Revision ID: f6ce1348e266
Revises:
Create Date: 2025-08-09 13:28:38.537652
"""
import sqlalchemy as sa
import sqlmodel as sqlm
from alembic import op
# revision identifiers, used by Alembic.
revision = "f6ce1348e266"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"api_keys",
sa.Column("hashed_key", sqlm.sql.sqltypes.AutoString(), nullable=False),
sa.Column("balance", sa.Integer(), nullable=False),
sa.Column("refund_address", sqlm.sql.sqltypes.AutoString(), nullable=True),
sa.Column("key_expiry_time", sa.Integer(), nullable=True),
sa.Column("total_spent", sa.Integer(), nullable=False),
sa.Column("total_requests", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("hashed_key"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("api_keys")
# ### end Alembic commands ###
+1
View File
@@ -11,6 +11,7 @@ dependencies = [
"sqlmodel>=0.0.24",
"httpx[socks]>=0.25.2",
"greenlet>=3.2.1",
"alembic>=1.13",
"python-json-logger>=2.0.0",
"cashu",
"secp256k1",
+46
View File
@@ -2,10 +2,16 @@ import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from alembic import command
from alembic.config import Config
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlmodel import Field, SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from .logging import get_logger
logger = get_logger(__name__)
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///keys.db")
@@ -29,6 +35,10 @@ class ApiKey(SQLModel, table=True): # type: ignore
default=0, description="Total spent in millisatoshis (msats)"
)
total_requests: int = Field(default=0)
mint_url: str | None = Field(
default=None,
description="URL of the mint used to create the cashu-token",
)
async def init_db() -> None:
@@ -46,3 +56,39 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
async def create_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSession(engine, expire_on_commit=False) as session:
yield session
def run_migrations() -> None:
"""Run Alembic migrations programmatically."""
import pathlib
try:
logger.info("Starting database migrations")
# Get the path to the alembic.ini file
project_root = pathlib.Path(__file__).resolve().parents[2]
alembic_ini_path = project_root / "alembic.ini"
if not alembic_ini_path.exists():
raise FileNotFoundError(
f"Alembic configuration file not found at {alembic_ini_path}"
)
# Create Alembic config object
alembic_cfg = Config(str(alembic_ini_path))
# Set the database URL in the config
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Run migrations to the latest revision
logger.info("Running migrations to latest revision")
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully")
except Exception as e:
logger.error(
"Database migration failed",
extra={"error": str(e), "error_type": type(e).__name__},
)
raise
+9 -1
View File
@@ -12,7 +12,7 @@ from ..payment.models import MODELS, models_router, update_sats_pricing
from ..proxy import proxy_router
from ..wallet import periodic_payout
from .admin import admin_router
from .db import init_db
from .db import init_db, run_migrations
from .logging import get_logger, setup_logging
# Initialize logging first
@@ -30,6 +30,14 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
payout_task = None
try:
# Run database migrations on startup
# This ensures the database schema is always up-to-date in production
# Migrations are idempotent - running them multiple times is safe
logger.info("Running database migrations")
run_migrations()
# Initialize database connection pools
# This creates any tables that might not be tracked by migrations yet
await init_db()
pricing_task = asyncio.create_task(update_sats_pricing())
Generated
+1365 -1337
View File
File diff suppressed because it is too large Load Diff