feat: add SQLAlchemy declarative base, Alembic environment, and gitignore fix

Configures stable constraint naming so autogeneration produces clean diffs.
Alembic uses the sync psycopg driver while the runtime app uses asyncpg.

Also fixes a .gitignore bug caught during this task: the bare 'models/'
rule for the ML weights volume was matching backend/app/models/ (Python
package). Anchored all volume rules to repo root (/images/, /import/,
/downloads/, /models/, /postgres_data/, /redis_data/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 07:32:58 -04:00
parent fe0a311d39
commit a03655039c
7 changed files with 150 additions and 7 deletions
+8 -7
View File
@@ -42,13 +42,14 @@ yarn-error.log*
extension/*.xpi extension/*.xpi
extension/web-ext-artifacts/ extension/web-ext-artifacts/
# Runtime volumes (must never be tracked) # Runtime volumes (must never be tracked) — anchored to repo root so they
images/ # don't accidentally match Python package dirs like backend/app/models/
import/ /images/
downloads/ /import/
models/ /downloads/
postgres_data/ /models/
redis_data/ /postgres_data/
/redis_data/
# IDE / OS # IDE / OS
.vscode/ .vscode/
+40
View File
@@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
file_template = %%(rev)s_%%(slug)s
version_path_separator = os
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[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
datefmt = %H:%M:%S
+53
View File
@@ -0,0 +1,53 @@
"""Alembic environment — reads DATABASE_URL from app config."""
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from backend.app.config import get_config
from backend.app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", get_config().database_url_sync)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
View File
+7
View File
@@ -0,0 +1,7 @@
"""All ORM models. Import this module to make every model visible to Alembic."""
from .base import Base
# Concrete models land in Task 6 and re-export here.
__all__ = ["Base"]
+17
View File
@@ -0,0 +1,17 @@
"""SQLAlchemy declarative base with standardized constraint naming."""
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase
# Stable constraint names so Alembic autogeneration produces clean diffs.
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)