From da9c19b2dc581afd0b7442278fd7f2346cb48dd4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 22 May 2026 08:11:42 -0400 Subject: [PATCH] fc5: migration 0015 + MigrationRun model (kind/status as String(32), API-validated) Co-Authored-By: Claude Opus 4.7 (1M context) --- alembic/versions/0015_fc5_migration_run.py | 51 ++++++++++++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/migration_run.py | 37 ++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 alembic/versions/0015_fc5_migration_run.py create mode 100644 backend/app/models/migration_run.py diff --git a/alembic/versions/0015_fc5_migration_run.py b/alembic/versions/0015_fc5_migration_run.py new file mode 100644 index 0000000..89d7d89 --- /dev/null +++ b/alembic/versions/0015_fc5_migration_run.py @@ -0,0 +1,51 @@ +"""fc5: migration_run table + +Revision ID: 0015 +Revises: 0014 +Create Date: 2026-05-22 + +Additive only. New table tracks each invocation of the FC-5 migration +tooling (backup, gs, ir, ml_queue, verify, rollback). kind/status are +plain String(32) — values validated at the API layer per the spec, not +a Postgres ENUM (so adding kinds later doesn't need a schema migration). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0015" +down_revision: Union[str, None] = "0014" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "migration_run", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("kind", sa.String(32), nullable=False, index=True), + sa.Column("status", sa.String(32), nullable=False, index=True), + sa.Column( + "dry_run", sa.Boolean(), nullable=False, server_default=sa.false(), + ), + sa.Column( + "started_at", sa.DateTime(timezone=True), + nullable=False, server_default=sa.func.now(), + ), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "counts", postgresql.JSONB, + nullable=False, server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("error", sa.Text(), nullable=True), + sa.Column( + "metadata", postgresql.JSONB, + nullable=False, server_default=sa.text("'{}'::jsonb"), + ), + ) + + +def downgrade() -> None: + op.drop_table("migration_run") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 609e2ff..0697c07 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,6 +10,7 @@ from .image_record import ImageRecord from .import_batch import ImportBatch from .import_settings import ImportSettings from .import_task import ImportTask +from .migration_run import MigrationRun from .ml_settings import MLSettings from .post import Post from .post_attachment import PostAttachment @@ -40,6 +41,7 @@ __all__ = [ "ImportTask", "ImportSettings", "MLSettings", + "MigrationRun", "TagAlias", "TagAllowlist", "TagReferenceEmbedding", diff --git a/backend/app/models/migration_run.py b/backend/app/models/migration_run.py new file mode 100644 index 0000000..bdb5fed --- /dev/null +++ b/backend/app/models/migration_run.py @@ -0,0 +1,37 @@ +"""MigrationRun — tracks each FC-5 migration invocation (backup/gs/ir/etc). + +kind/status are String(32) not Postgres ENUM so adding kinds later +doesn't need a schema migration. The API layer validates values. +""" + +from datetime import datetime + +import sqlalchemy as sa +from sqlalchemy import Boolean, DateTime, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class MigrationRun(Base): + __tablename__ = "migration_run" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + kind: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), + ) + finished_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, + ) + counts: Mapped[dict] = mapped_column( + JSONB, nullable=False, default=dict, server_default=sa.text("'{}'::jsonb"), + ) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_: Mapped[dict] = mapped_column( + "metadata", JSONB, nullable=False, default=dict, + server_default=sa.text("'{}'::jsonb"), + )