feat(backend): каркас — конфиг, движок БД, базовые модели и первая миграция

SQLAlchemy 2 async на asyncpg, Alembic, pydantic-settings. Деньги везде
NUMERIC(24,10) с колонкой валюты рядом (db/base.py), postgres-enum'ы хранят
значения, а не имена, чтобы база читалась так же, как API.

Первая миграция: app_user, account, portfolio, instrument, sync_run, sync_job.
metrics/refresh.py задаёт порядок пересборки metric_* и сериализует параллельные
пересчёты advisory-локом на отдельном соединении: каждый шаг заменяет свою
таблицу целиком, два одновременных прогона затёрли бы друг друга.
This commit is contained in:
Dmitry
2026-09-18 13:43:31 +03:00
parent 220f027652
commit 295438914d
19 changed files with 3406 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
"""Sync bookkeeping: cursors, run log, manual job queue, per-source credentials."""
from __future__ import annotations
import enum
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from fintracker.db.base import Base, db_enum
class RunStatus(enum.StrEnum):
running = "running"
ok = "ok"
error = "error"
class JobStatus(enum.StrEnum):
queued = "queued"
running = "running"
done = "done"
error = "error"
class SyncState(Base):
"""One row per source: where the incremental sync left off."""
__tablename__ = "sync_state"
source: Mapped[str] = mapped_column(String(64), primary_key=True)
cursor: Mapped[str | None] = mapped_column(Text)
last_run_at: Mapped[datetime | None]
last_success_at: Mapped[datetime | None]
class SyncRun(Base):
__tablename__ = "sync_run"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
source: Mapped[str] = mapped_column(String(64), index=True)
status: Mapped[RunStatus] = mapped_column(db_enum(RunStatus, "run_status"))
triggered_by: Mapped[str] = mapped_column(String(32))
"""schedule | manual | cli"""
started_at: Mapped[datetime] = mapped_column(server_default=func.now())
finished_at: Mapped[datetime | None]
cursor_before: Mapped[str | None] = mapped_column(Text)
cursor_after: Mapped[str | None] = mapped_column(Text)
counts: Mapped[dict[str, Any] | None]
warnings: Mapped[list[Any] | None]
error: Mapped[str | None] = mapped_column(Text)
class SyncJob(Base):
"""Manual trigger queue: API inserts, worker picks up (plan §2.4)."""
__tablename__ = "sync_job"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
source: Mapped[str] = mapped_column(String(64), index=True)
status: Mapped[JobStatus] = mapped_column(db_enum(JobStatus, "job_status"), index=True)
requested_at: Mapped[datetime] = mapped_column(server_default=func.now())
started_at: Mapped[datetime | None]
finished_at: Mapped[datetime | None]
run_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("sync_run.id", ondelete="SET NULL"))
error: Mapped[str | None] = mapped_column(Text)
class SourceCredential(Base):
"""Rotating credentials a source manages itself (e.g. ZenMoney refresh token).
Static tokens still come from the environment; this is for what the worker must
be able to update unattended.
"""
__tablename__ = "source_credential"
source: Mapped[str] = mapped_column(String(64), primary_key=True)
payload: Mapped[dict[str, Any]]
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())