feat(fc2a): schema migration 0002 — tag kinds + fandom hierarchy + import lifecycle

Adds Tag.kind enum (artist/character/fandom/general/series/archive/post/meta/rating),
Tag.fandom_id FK with CHECK constraint (only valid for kind='character'), and a
kind-aware uniqueness index so the same name can exist across kinds and the same
character name can exist in different fandoms.

Adds ImportBatch + ImportTask state-machine tables for scan tracking, plus a
single-row ImportSettings table (CHECK id=1) holding the importer's filter knobs.

Adds image_record.integrity_status column (defaults to 'unknown'); FC-2e
populates this via the integrity verifier.

Drops the unused tag.namespace column from FC-1 — superseded by kind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:03:41 -04:00
parent ff122c55eb
commit 9eb1614fbf
8 changed files with 425 additions and 8 deletions
@@ -0,0 +1,208 @@
"""fc2a: tag kinds, import_task, import_batch, integrity_status
Revision ID: 0002
Revises: 0001
Create Date: 2026-05-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TAG_KINDS = (
"artist",
"character",
"fandom",
"general",
"series",
"archive",
"post",
"meta",
"rating",
)
def upgrade() -> None:
# --- Tag kind enum + fandom_id ---
tag_kind = sa.Enum(*TAG_KINDS, name="tag_kind")
tag_kind.create(op.get_bind(), checkfirst=True)
op.add_column(
"tag",
sa.Column("kind", tag_kind, nullable=False, server_default="general"),
)
op.add_column(
"tag",
sa.Column("fandom_id", sa.Integer(), nullable=True),
)
op.create_foreign_key(
"fk_tag_fandom_id_tag",
"tag",
"tag",
["fandom_id"],
["id"],
ondelete="SET NULL",
)
# Drop the old global uniqueness on name; add kind+fandom-aware uniqueness.
op.drop_constraint("uq_tag_name", "tag", type_="unique")
op.drop_index("ix_tag_name", table_name="tag")
op.execute(
"""
CREATE UNIQUE INDEX uq_tag_name_kind_fandom
ON tag (name, kind, COALESCE(fandom_id, 0))
"""
)
# CHECK: fandom_id is only allowed for character kind.
op.create_check_constraint(
"ck_tag_fandom_requires_character",
"tag",
"(fandom_id IS NULL) OR (kind = 'character')",
)
# Drop the old namespace column — superseded by kind.
op.drop_index("ix_tag_namespace", table_name="tag")
op.drop_column("tag", "namespace")
# --- ImportBatch ---
op.create_table(
"import_batch",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("triggered_by", sa.String(length=32), nullable=False),
sa.Column("source_path", sa.Text(), nullable=False),
sa.Column("scan_mode", sa.String(length=16), nullable=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("total_files", sa.Integer(), nullable=False, server_default="0"),
sa.Column("imported", sa.Integer(), nullable=False, server_default="0"),
sa.Column("skipped", sa.Integer(), nullable=False, server_default="0"),
sa.Column("failed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("status", sa.String(length=16), nullable=False, server_default="running"),
sa.PrimaryKeyConstraint("id", name="pk_import_batch"),
)
op.create_index("ix_import_batch_status", "import_batch", ["status"])
# --- ImportTask ---
op.create_table(
"import_task",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("batch_id", sa.Integer(), nullable=False),
sa.Column("source_path", sa.Text(), nullable=False),
sa.Column("task_type", sa.String(length=16), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
sa.Column("result_image_id", sa.Integer(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("size_bytes", sa.BigInteger(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["batch_id"],
["import_batch.id"],
name="fk_import_task_batch_id_import_batch",
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["result_image_id"],
["image_record.id"],
name="fk_import_task_result_image_id_image_record",
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name="pk_import_task"),
)
op.create_index("ix_import_task_batch_id", "import_task", ["batch_id"])
op.create_index("ix_import_task_status", "import_task", ["status"])
op.create_index(
"ix_import_task_created_at_desc",
"import_task",
[sa.text("created_at DESC")],
)
# --- ImportSettings (single-row table) ---
op.create_table(
"import_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("import_scan_path", sa.Text(), nullable=False, server_default="/import"),
sa.Column("min_width", sa.Integer(), nullable=False, server_default="0"),
sa.Column("min_height", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"skip_transparent", sa.Boolean(), nullable=False, server_default=sa.false()
),
sa.Column(
"transparency_threshold",
sa.Float(),
nullable=False,
server_default="0.9",
),
sa.Column(
"skip_single_color", sa.Boolean(), nullable=False, server_default=sa.false()
),
sa.Column(
"single_color_threshold",
sa.Float(),
nullable=False,
server_default="0.95",
),
sa.Column("single_color_tolerance", sa.Integer(), nullable=False, server_default="30"),
sa.PrimaryKeyConstraint("id", name="pk_import_settings"),
sa.CheckConstraint("id = 1", name="ck_import_settings_singleton"),
)
# Seed the single row immediately so callers can always SELECT id=1.
op.execute("INSERT INTO import_settings (id) VALUES (1)")
# --- ImageRecord additions ---
op.add_column(
"image_record",
sa.Column(
"integrity_status",
sa.String(length=24),
nullable=False,
server_default="unknown",
),
)
op.create_index(
"ix_image_record_integrity_status",
"image_record",
["integrity_status"],
)
def downgrade() -> None:
op.drop_index("ix_image_record_integrity_status", table_name="image_record")
op.drop_column("image_record", "integrity_status")
op.drop_table("import_settings")
op.drop_index("ix_import_task_created_at_desc", table_name="import_task")
op.drop_index("ix_import_task_status", table_name="import_task")
op.drop_index("ix_import_task_batch_id", table_name="import_task")
op.drop_table("import_task")
op.drop_index("ix_import_batch_status", table_name="import_batch")
op.drop_table("import_batch")
op.drop_constraint("ck_tag_fandom_requires_character", "tag", type_="check")
op.execute("DROP INDEX uq_tag_name_kind_fandom")
op.add_column("tag", sa.Column("namespace", sa.String(length=64), nullable=True))
op.create_index("ix_tag_namespace", "tag", ["namespace"])
op.create_index("ix_tag_name", "tag", ["name"], unique=False)
op.create_unique_constraint("uq_tag_name", "tag", ["name"])
op.drop_constraint("fk_tag_fandom_id_tag", "tag", type_="foreignkey")
op.drop_column("tag", "fandom_id")
op.drop_column("tag", "kind")
sa.Enum(name="tag_kind").drop(op.get_bind(), checkfirst=True)