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:
@@ -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())
|
||||
Reference in New Issue
Block a user