feat(sprint2): инфраструктура БД — SQLAlchemy 2.0 async + Alembic

Первый кусок Спринта 2: подключаем SQLite через SQLAlchemy 2.0 (async,
ORM-стиль) и Alembic для миграций. Схема выбрана под будущий рост —
в threads сразу заведены nullable user_id и agent_config_id, чтобы
Спринты 3+ не тащили миграции задним числом.

- requirements.txt: sqlalchemy[asyncio]==2.0.36, aiosqlite==0.20.0,
  alembic==1.14.0.
- config: database_url + sqlite_path (./data/sqlite/app.db).
- db/base.py: DeclarativeBase; db/session.py: async engine,
  async_sessionmaker, get_session — FastAPI-dependency.
- db/models/Thread: id, name, user_id?, agent_config_id?, created_at,
  updated_at; relationship messages с cascade all, delete-orphan.
- db/models/Message: id, thread_id FK CASCADE, role, text, sources_json,
  assembled_prompt, created_at.
- Alembic инициализирован через async-шаблон, env.py доработан:
  sys.path, url из settings, target_metadata = Base.metadata.
- Начальная миграция e7199587be4b применена, таблицы threads/messages
  с индексами на FK и nullable-колонки созданы в data/sqlite/app.db.
- .gitignore: исключаем data/sqlite/ (БД — артефакт, не исходник).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-04-23 10:05:10 +05:00
parent 4a5695ed9c
commit 75048bb88e
14 changed files with 384 additions and 0 deletions
View File
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+4
View File
@@ -0,0 +1,4 @@
from db.models.message import Message
from db.models.thread import Thread
__all__ = ["Thread", "Message"]
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
if TYPE_CHECKING:
from db.models.thread import Thread
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class Message(Base):
__tablename__ = "messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
thread_id: Mapped[int] = mapped_column(
ForeignKey("threads.id", ondelete="CASCADE"), nullable=False, index=True
)
role: Mapped[str] = mapped_column(String(20), nullable=False) # "user" | "assistant"
text: Mapped[str] = mapped_column(Text, nullable=False)
sources_json: Mapped[str | None] = mapped_column(Text, nullable=True)
assembled_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
thread: Mapped["Thread"] = relationship(back_populates="messages")
+36
View File
@@ -0,0 +1,36 @@
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
if TYPE_CHECKING:
from db.models.message import Message
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class Thread(Base):
__tablename__ = "threads"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
# Зарезервировано под Спринты 3+: мульти-пользователи и мульти-промпты.
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
agent_config_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False
)
messages: Mapped[list["Message"]] = relationship(
back_populates="thread",
cascade="all, delete-orphan",
order_by="Message.created_at",
)
+14
View File
@@ -0,0 +1,14 @@
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from config import settings
engine = create_async_engine(settings.database_url, echo=False, future=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session