Revert "db: collapse alembic 0001..0087 into one baseline"

This reverts 2529b51. Not a retreat — a reordering, on the operator's
call, and the better sequence.

The squash's acceptance test (run 4971) found ~130 places where the ORM
models do not describe the deployed schema (#3275), including a
unique=True the database never had and two UNIQUE indexes that exist
only in migrations. Collapsing now would have baked all of that into the
one file a public installer starts from.

So: fix the drift first as ordinary migrations on the intact chain, let
the operator deploy so their database moves to the corrected head, and
only then collapse. The baseline is then generated from reconciled
models and reproduces a schema worth reproducing.

Nothing is lost by reverting. The baseline was never deployed, and
regenerating it after the fixes is strictly better than patching this
copy — it will come out of autogenerate correct rather than needing the
same hand-finishing twice.
This commit is contained in:
2026-08-30 14:34:28 -04:00
parent 2529b516e6
commit 6959e1220c
99 changed files with 6579 additions and 872 deletions
+58
View File
@@ -0,0 +1,58 @@
"""Smoke test for migration 0002: confirms model classes import and the
tag-kind uniqueness rule shape is correct.
"""
from backend.app.models import (
Base,
ImportBatch,
ImportSettings,
ImportTask,
Tag,
TagKind,
)
def test_new_tables_registered():
expected = {"import_batch", "import_task", "import_settings"}
assert expected.issubset(Base.metadata.tables.keys())
def test_tag_has_kind_and_fandom_id():
cols = {c.name for c in Tag.__table__.columns}
assert "kind" in cols
assert "fandom_id" in cols
assert "namespace" not in cols
def test_tag_kind_enum_values():
# Current TagKind enum after alembic 0023 dropped meta + rating
# (operator-retired 2026-05-26). `artist` is still in the enum
# for backward-compat with historical rows, though new artist
# tags don't get created (Artist row is canonical per FC-2d-vii-c).
expected = {
"artist",
"character",
"fandom",
"general",
"series",
"archive",
"post",
}
assert {k.value for k in TagKind} == expected
def test_image_record_has_integrity_status():
from backend.app.models import ImageRecord
cols = {c.name for c in ImageRecord.__table__.columns}
assert "integrity_status" in cols
def test_import_task_has_state_columns():
cols = {c.name for c in ImportTask.__table__.columns}
for required in ("batch_id", "source_path", "task_type", "status", "result_image_id"):
assert required in cols
def test_import_settings_singleton_constraint():
constraints = {c.name for c in ImportSettings.__table__.constraints}
assert "ck_import_settings_singleton" in constraints
+46
View File
@@ -0,0 +1,46 @@
"""Smoke test for migration 0003: model classes import, schema shape correct."""
from backend.app.models import (
Base,
ImageRecord,
MLSettings,
TagAlias,
TagSuggestionRejection,
)
def test_new_tables_registered():
expected = {
"tag_suggestion_rejection",
"tag_alias",
"ml_settings",
}
assert expected.issubset(Base.metadata.tables.keys())
def test_image_record_columns_renamed():
cols = {c.name for c in ImageRecord.__table__.columns}
# Legacy tagger columns are all gone: tagger_predictions/wd14_* dropped in
# 0046, tagger_model_version + centroid_scores dropped in 0068 (#1199, Camie
# retirement). The SigLIP embedding columns are the live ML fields.
assert "siglip_embedding" in cols
assert "siglip_model_version" in cols
assert "tagger_model_version" not in cols
assert "centroid_scores" not in cols
assert "tagger_predictions" not in cols
assert "wd14_predictions" not in cols
def test_tag_alias_composite_pk():
pk_cols = {c.name for c in TagAlias.__table__.primary_key.columns}
assert pk_cols == {"alias_string", "alias_category"}
def test_ml_settings_singleton_constraint():
names = {c.name for c in MLSettings.__table__.constraints}
assert "ck_ml_settings_singleton" in names
def test_tag_suggestion_rejection_pk():
pk_cols = {c.name for c in TagSuggestionRejection.__table__.primary_key.columns}
assert pk_cols == {"image_record_id", "tag_id"}
+27
View File
@@ -0,0 +1,27 @@
"""Integration: the tsm_system_rows extension is installed by migration 0004.
Needs a real Postgres (CI does not provision one), so integration-marked.
"""
import pytest
from sqlalchemy import text
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
async def test_tsm_system_rows_extension_present(db):
row = (
await db.execute(
text("SELECT 1 FROM pg_extension WHERE extname = 'tsm_system_rows'")
)
).first()
assert row is not None
@pytest.mark.asyncio
async def test_system_rows_sampling_is_usable(db):
# Should parse and execute even on an empty table.
await db.execute(
text("SELECT * FROM image_record TABLESAMPLE SYSTEM_ROWS(1)")
)
+48
View File
@@ -0,0 +1,48 @@
"""FC-2d-iv: post.description + post.attachment_count round-trip."""
from datetime import UTC, datetime
import pytest
from backend.app.models import Artist, Post, Source
pytestmark = pytest.mark.integration
async def _post(db, **post_kwargs):
artist = Artist(name="Nadia", slug="nadia")
db.add(artist)
await db.flush()
src = Source(artist_id=artist.id, platform="web", url="http://x")
db.add(src)
await db.flush()
post = Post(
source_id=src.id, artist_id=artist.id, external_post_id="p1",
post_date=datetime(2026, 3, 1, tzinfo=UTC),
**post_kwargs,
)
db.add(post)
await db.flush()
return post.id
def test_post_has_new_columns():
cols = {c.name for c in Post.__table__.columns}
assert "description" in cols
assert "attachment_count" in cols
@pytest.mark.asyncio
async def test_description_and_attachment_count_round_trip(db):
pid = await _post(db, description="<p>hi</p>", attachment_count=3)
row = await db.get(Post, pid)
assert row.description == "<p>hi</p>"
assert row.attachment_count == 3
@pytest.mark.asyncio
async def test_new_fields_default_null(db):
pid = await _post(db)
row = await db.get(Post, pid)
assert row.description is None
assert row.attachment_count is None
+137
View File
@@ -0,0 +1,137 @@
"""FC-2d-vii-c: image_record.artist_id + backfill + artist-tag delete."""
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import func, select, text
from backend.app.models import (
Artist,
ImageProvenance,
ImageRecord,
Post,
Source,
Tag,
TagKind,
)
from backend.app.models.tag import image_tag
from backend.app.utils.artist_backfill import (
BACKFILL_PRIMARY_SQL,
BACKFILL_PROVENANCE_SQL,
BACKFILL_TAG_SQL,
DELETE_ARTIST_TAGS_SQL,
)
pytestmark = pytest.mark.integration
def test_image_record_has_artist_id_column():
assert "artist_id" in {c.name for c in ImageRecord.__table__.columns}
async def _img(db, n):
rec = ImageRecord(
path=f"/images/bf/{n}.jpg", sha256=f"bf{n:062d}",
size_bytes=1, mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
)
rec.created_at = datetime.now(UTC) - timedelta(minutes=n)
db.add(rec)
await db.flush()
return rec
async def _artist_source(db, name, slug):
a = Artist(name=name, slug=slug)
db.add(a)
await db.flush()
s = Source(artist_id=a.id, platform="patreon",
url=f"https://p.test/{slug}")
db.add(s)
await db.flush()
return a, s
async def _run_backfill(db):
await db.execute(text(BACKFILL_PRIMARY_SQL))
await db.execute(text(BACKFILL_PROVENANCE_SQL))
await db.execute(text(BACKFILL_TAG_SQL))
@pytest.mark.asyncio
async def test_backfill_primary_post(db):
rec = await _img(db, 1)
a, s = await _artist_source(db, "Alice", "alice")
post = Post(source_id=s.id, artist_id=a.id, external_post_id="1")
db.add(post)
await db.flush()
rec.primary_post_id = post.id
await db.flush()
await _run_backfill(db)
got = await db.scalar(
select(ImageRecord.artist_id).where(ImageRecord.id == rec.id)
)
assert got == a.id
@pytest.mark.asyncio
async def test_backfill_provenance_fallback(db):
rec = await _img(db, 1)
a, s = await _artist_source(db, "Bob", "bob")
post = Post(source_id=s.id, artist_id=a.id, external_post_id="2")
db.add(post)
await db.flush()
db.add(ImageProvenance(image_record_id=rec.id, post_id=post.id,
source_id=s.id))
await db.flush()
await _run_backfill(db)
got = await db.scalar(
select(ImageRecord.artist_id).where(ImageRecord.id == rec.id)
)
assert got == a.id
@pytest.mark.asyncio
async def test_backfill_artist_tag_by_name(db):
rec = await _img(db, 1)
a = Artist(name="Carol", slug="carol")
db.add(a)
await db.flush()
tag = Tag(name="Carol", kind=TagKind.artist)
db.add(tag)
await db.flush()
await db.execute(image_tag.insert().values(
image_record_id=rec.id, tag_id=tag.id, source="auto"))
await db.flush()
await _run_backfill(db)
got = await db.scalar(
select(ImageRecord.artist_id).where(ImageRecord.id == rec.id)
)
assert got == a.id
@pytest.mark.asyncio
async def test_no_signal_stays_null(db):
rec = await _img(db, 1)
await _run_backfill(db)
got = await db.scalar(
select(ImageRecord.artist_id).where(ImageRecord.id == rec.id)
)
assert got is None
@pytest.mark.asyncio
async def test_delete_removes_only_artist_tags(db):
artist_tag = Tag(name="Dave", kind=TagKind.artist)
general_tag = Tag(name="forest", kind=TagKind.general)
db.add_all([artist_tag, general_tag])
await db.flush()
await db.execute(text(DELETE_ARTIST_TAGS_SQL))
remaining = await db.scalar(
select(func.count()).select_from(Tag).where(Tag.kind == TagKind.artist)
)
assert remaining == 0
survived = await db.scalar(
select(func.count()).select_from(Tag).where(Tag.kind == TagKind.general)
)
assert survived >= 1
+37
View File
@@ -0,0 +1,37 @@
"""FC-2d-iii: post_attachment table + import_batch.attachments column."""
import pytest
from backend.app.models import ImportBatch, PostAttachment
pytestmark = pytest.mark.integration
def test_post_attachment_columns():
cols = {c.name for c in PostAttachment.__table__.columns}
assert {
"id", "post_id", "artist_id", "sha256", "path",
"original_filename", "ext", "mime", "size_bytes", "captured_at",
} <= cols
def test_import_batch_has_attachments_counter():
assert "attachments" in {c.name for c in ImportBatch.__table__.columns}
@pytest.mark.asyncio
async def test_post_attachment_roundtrip(db):
from backend.app.models import Artist
a = Artist(name="Zed", slug="zed")
db.add(a)
await db.flush()
att = PostAttachment(
post_id=None, artist_id=a.id, sha256="z" + "0" * 63,
path="/images/attachments/z00/z.zip", original_filename="pack.zip",
ext=".zip", mime="application/zip", size_bytes=123,
)
db.add(att)
await db.flush()
got = await db.get(PostAttachment, att.id)
assert got.original_filename == "pack.zip" and got.post_id is None
+35
View File
@@ -0,0 +1,35 @@
import pytest
from sqlalchemy.exc import IntegrityError
from backend.app.models import Artist, Source
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
async def test_duplicate_artist_platform_url_rejected(db):
artist = Artist(name="Alice", slug="alice")
db.add(artist)
await db.flush()
db.add(Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice", enabled=True,
))
await db.flush()
db.add(Source(
artist_id=artist.id, platform="patreon",
url="https://patreon.com/alice", enabled=True,
))
with pytest.raises(IntegrityError):
await db.flush()
@pytest.mark.asyncio
async def test_same_url_under_different_artist_ok(db):
a = Artist(name="A", slug="a")
b = Artist(name="B", slug="b")
db.add_all([a, b])
await db.flush()
db.add(Source(artist_id=a.id, platform="patreon", url="https://x/y", enabled=True))
db.add(Source(artist_id=b.id, platform="patreon", url="https://x/y", enabled=True))
await db.flush() # must NOT raise
+32
View File
@@ -0,0 +1,32 @@
import pytest
from sqlalchemy import inspect, text
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
async def test_credential_has_credential_type_not_kind(db):
cols = (await db.run_sync(
lambda sync_session: [c["name"] for c in inspect(sync_session.bind).get_columns("credential")]
))
assert "credential_type" in cols
assert "kind" not in cols
assert "status" not in cols
assert "last_verified" in cols
@pytest.mark.asyncio
async def test_credential_round_trip(db):
from backend.app.models import Credential
db.add(Credential(
platform="patreon",
credential_type="cookies",
encrypted_blob=b"\x00\x01\x02",
))
await db.flush()
row = (await db.execute(
text("SELECT credential_type, last_verified FROM credential WHERE platform='patreon'")
)).one()
assert row.credential_type == "cookies"
assert row.last_verified is None
+32
View File
@@ -0,0 +1,32 @@
import pytest
from sqlalchemy import select
from backend.app.models import AppSetting
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
async def test_app_setting_table_round_trip(db):
db.add(AppSetting(key="extension_api_key", value="abc123"))
await db.flush()
row = (await db.execute(
select(AppSetting).where(AppSetting.key == "extension_api_key")
)).scalar_one()
assert row.value == "abc123"
assert row.updated_at is not None
@pytest.mark.asyncio
async def test_app_setting_upsert(db):
db.add(AppSetting(key="k", value="v1"))
await db.flush()
row = (await db.execute(
select(AppSetting).where(AppSetting.key == "k")
)).scalar_one()
row.value = "v2"
await db.flush()
again = (await db.execute(
select(AppSetting.value).where(AppSetting.key == "k")
)).scalar_one()
assert again == "v2"
+31
View File
@@ -0,0 +1,31 @@
import pytest
from sqlalchemy import inspect, select
from backend.app.models import ImportSettings
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
async def test_download_event_has_metadata(db):
cols = await db.run_sync(
lambda s: {c["name"]: c for c in inspect(s.bind).get_columns("download_event")}
)
assert "metadata" in cols
assert cols["metadata"]["nullable"] is False
@pytest.mark.asyncio
async def test_import_settings_has_downloader_fields(db):
cols = await db.run_sync(
lambda s: {c["name"]: c for c in inspect(s.bind).get_columns("import_settings")}
)
assert "download_rate_limit_seconds" in cols
assert "download_validate_files" in cols
@pytest.mark.asyncio
async def test_import_settings_defaults(db):
row = (await db.execute(select(ImportSettings).where(ImportSettings.id == 1))).scalar_one()
assert row.download_rate_limit_seconds == 3.0
assert row.download_validate_files is True