db: collapse alembic 0001..0087 into one baseline (milestone 328 step 1)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
Build images / build-ml (push) Successful in 42s
CI / backend-lint-and-test (push) Successful in 53s
Build images / build-web (push) Successful in 33s
CI / integration (push) Failing after 3m47s
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
Build images / build-ml (push) Successful in 42s
CI / backend-lint-and-test (push) Successful in 53s
Build images / build-web (push) Successful in 33s
CI / integration (push) Failing after 3m47s
87 revisions narrating this project's build-out become one file that creates the schema in a single step. They cost nothing at runtime — all 86 upgrade steps ran in 0.2s (note #3260) — so this is a presentation change, not a performance one: a new installer should not inherit our development history to stand up a database. Deleted: 87 revisions (6,052 lines), the 10 tests/test_migration_*.py files (483 lines) that asserted intermediate states and backfills which no longer exist, and backend/app/utils/artist_backfill.py — the only live module a migration imported, with no other consumer anywhere. That last one satisfies the operator's separate request to inline it into 0008 and delete the module; the squash removes both outright. THE REVISION ID IS "0087", NOT "0001", ON PURPOSE. It is the id of the last revision collapsed, so an existing database is already at head and `alembic upgrade head` does nothing. The alternative is `alembic stamp` against live data, and stamp validates NOTHING — it writes a version string whether or not the schema matches, so a wrong baseline surfaces later, via the next real migration, with no clean way back. This removes that operation rather than making it safe. Future revisions run from 0088. Four things are hand-written because SQLAlchemy metadata does not carry them, and none fail at generation time: 1. CREATE EXTENSION vector — the VECTOR columns cannot be created without it, so it is ordered first in upgrade(). 2. CREATE EXTENSION tsm_system_rows — surfaces only when the random sample query runs. 3. the HNSW index on image_record.siglip_embedding, raw SQL because create_index cannot express USING hnsw (... vector_cosine_ops). The quietest of the four: everything works, similarity search just stops using an index. 4. import pgvector.sqlalchemy.vector — autogenerate EMITS pgvector.sqlalchemy.vector.VECTOR references without importing it, so the generated file dies with NameError on first run. The candidate came out of CI (run 4967) as checksummed base64 rather than a plain cat, because run 4964's cat was truncated mid-line inside a column definition with the step still green — 29 tables instead of 42, and it looked entirely plausible. Verified here: 56,582 bytes, sha256 471acfca69c0…, 42 tables, 66 indexes, 42 drops. NOT YET PROVEN against the old chain. baseline.yml does that, and it is step 2's gate; this commit does not claim the schemas match.
This commit is contained in:
@@ -1,58 +0,0 @@
|
||||
"""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
|
||||
@@ -1,46 +0,0 @@
|
||||
"""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"}
|
||||
@@ -1,27 +0,0 @@
|
||||
"""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)")
|
||||
)
|
||||
@@ -1,48 +0,0 @@
|
||||
"""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
|
||||
@@ -1,137 +0,0 @@
|
||||
"""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
|
||||
@@ -1,37 +0,0 @@
|
||||
"""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
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
@@ -1,32 +0,0 @@
|
||||
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
|
||||
@@ -1,32 +0,0 @@
|
||||
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"
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
Reference in New Issue
Block a user