"""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" METRICS_JOB = "metrics" """`sync_job.source` of a queued metrics rebuild. Not a registered source: the worker routes it to `refresh_all` instead of `run_source`, and `POST /sync/{source}` answers 404.""" 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). Also carries the metrics rebuild requested by `POST /metrics/refresh`, as `source == METRICS_JOB`.""" __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())