Files
core-api/alembic/env.py
T
jpmschweitzerandClaude Sonnet 5 3a3dc959be fix(alembic): refuse to run without a named migration target (T-3)
`alembic/env.py` took its target from `get_settings().database_url`,
composed from `.env`'s production `POSTGRES_*` values, so the most natural
command in the repo — a bare `alembic current` — talked to production.
`-x target=test` and `-x target=production` now name the database
explicitly; anything else exits non-zero before any engine is built, and
the one line it prints before migrating never shows a credential.

`run_alembic` (tests/conftest.py) passes `-x target=test` and sets
`TEST_DATABASE_URL` in the subprocess environment instead of overriding
`POSTGRES_*` — the `_components()` shim is gone; nothing else used it.

`make migrate-test`, `make migrate-production` and `make dev-test-server`
are the three documented ways to reach either database now; `dev-test-server`
refuses to start if the resolved database is not `core_api_test`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 00:26:48 +02:00

174 lines
5.2 KiB
Python

"""
Alembic Environment Configuration
Async migration environment for SQLAlchemy 2.0 with asyncpg.
Alembic refuses to run without an explicit target (core-api T-3): a bare
`alembic current` used to fall back to `get_settings().database_url`, which
is composed from `.env`'s production `POSTGRES_*` values — the most natural
command in the repo was quietly talking to production. `-x target=test`
names the database `TEST_DATABASE_URL` points at; `-x target=production`
names today's settings-derived database, deliberately. Nothing else
defaults, and the check runs — and can exit — before any engine is built.
"""
import asyncio
import os
import re
import sys
from logging.config import fileConfig
from pathlib import Path
from sqlalchemy import pool
from sqlalchemy.engine import Connection, make_url
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import our models (config is imported lazily, inside _production_url, so
# that resolving a bad or missing target never has to construct Settings).
from src.shared.database import Base
# Import all models to ensure they're registered with Base.metadata
from src.domains.auth.models import User, Role, UserRole, UserPreferences, ApiKey # noqa: F401
from src.domains.dashboard.models import QuickLink, DashboardWidget # noqa: F401
from src.domains.book.models import ( # noqa: F401
BookRecord, BookTag, BookRecordLink, BookAttachment, BookEvent, BookTenantCounter,
)
from src.domains.scribe.models import ( # noqa: F401
ScribeAddress, ScribeMessage, ScribeMailboxEntry,
)
from src.domains.tenants.models import Tenant, TenantConsumer # noqa: F401
REPO_ROOT = Path(__file__).resolve().parent.parent
# Alembic Config object
config = context.config
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Model metadata for autogenerate support
target_metadata = Base.metadata
def _test_url() -> str:
"""TEST_DATABASE_URL from the environment, else the repo's own `.env` —
read that one variable only; the file is never loaded or sourced whole."""
value = os.environ.get("TEST_DATABASE_URL")
if value:
return value
env_path = REPO_ROOT / ".env"
if env_path.is_file():
with open(env_path) as f:
for line in f:
match = re.match(r"^TEST_DATABASE_URL=(.*)$", line.rstrip("\n"))
if match:
return match.group(1).strip().strip("'\"")
raise SystemExit(
"alembic: -x target=test needs TEST_DATABASE_URL (environment, or "
"the repo's .env) — none found."
)
def _production_url() -> str:
"""Today's settings-derived database — deliberately, only when asked
for by name."""
from src.shared.config import get_settings
return get_settings().database_url
def _resolve_target_url() -> str:
"""-x target=test|production names the database to migrate. No target,
or anything else, exits non-zero here — before any engine is built."""
x_args = context.get_x_argument(as_dictionary=True)
target = x_args.get("target")
if target == "test":
url = _test_url()
elif target == "production":
url = _production_url()
else:
raise SystemExit(
"alembic: no migration target named. Pass one of:\n"
" -x target=test (the database TEST_DATABASE_URL names)\n"
" -x target=production (today's settings-derived database)\n"
f"Got -x target={target!r}. Nothing defaults."
)
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
return url
db_url = _resolve_target_url()
# Name the target before migrating anything — never a host's credentials,
# never the URL itself.
print(f"alembic target: database={make_url(db_url).database!r}", file=sys.stderr)
def run_migrations_offline() -> None:
"""
Run migrations in 'offline' mode.
Generates SQL script without connecting to the database.
"""
context.configure(
url=db_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:
"""
Run migrations with the given connection.
"""
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""
Run migrations in 'online' mode with async engine.
"""
configuration = config.get_section(config.config_ini_section) or {}
configuration["sqlalchemy.url"] = db_url
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""
Run migrations in 'online' mode.
"""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()