All defaults match. Verification complete — everything is green with no defects found. **Phase 147 final verification pass — report** - All 3 task files found in `complete/147_sync_changed_only/` → verification pass only; no code changes needed. - Verified: migration 0025 (`documents.file_hash` nullable String(64), no index) + model field; fast path at top of `_index_file` (streamed 1 MiB sha256 → row lookup → `_unchanged_doc`); stamping on all three branches' create/update + shared helper; pre-phase NULL-hash fallthrough; retriever/`content_hash` untouched. - **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → 3438 passed, `app/` coverage **99%** (>90% ✓); `uv run pytest tests/e2e/test_sync_changed_only.py -v --no-cov` → 3 passed in isolation ✓; `uv run ruff check . && uv run pyright` → all checks passed, 0 errors ✓; prior-phase `tests/e2e/test_sync_button.py` → 3 passed (no regression) ✓. - **Completion criteria:** all 7 pass — (1) column + round-trip ✓ (2) unchanged text/image/garbage-PDF/pictorial-PDF cost zero LLM/extraction/vision, `summary.unchanged` counted, rows byte-still ✓ (3) changed file re-indexes + re-stamps; bytes-changed/content-same stamps only ✓ (4) backfill/D1/D4 + log wordings pinned verbatim ✓ (5) `content_hash`/retriever byte-identical ✓ (6) all gates green ✓ (7) no prior-phase behavior change ✓. - **Documentation:** full review of README.md (1,031 lines) and AGENTS.md against the code: every `.env.example`/code `BOR_*` var documented, all config defaults match `app/config.py`, quick-start/QA commands and referenced test files exist, `file://` URL + troubleshooting claims accurate; the phase's README "Updating the documents" edit is accurate. **Verified consistent — no changes needed.** - **Notable:** no live-infrastructure changes (dev Postgres container used for the standard test gates only). Working tree left uncommitted per harness protocol. - **Next pending phase:** `148_pdf_pages_in_sources`.
367 lines
14 KiB
Python
367 lines
14 KiB
Python
"""Integration: migration 0025 (documents.file_hash) schema contract
|
|
(phase 147, task 01).
|
|
|
|
Drives the **real Alembic engine** against the live dev database
|
|
(``podman compose up -d db``), mirroring the house pattern of
|
|
``test_migration_0024.py`` (information_schema assertions on the state
|
|
the migration must leave). The tests target the 0024 → 0025 step
|
|
explicitly so later migrations cannot break the pins:
|
|
|
|
* upgrade 0024 → 0025 → ``file_hash`` exists with the full contract —
|
|
VARCHAR(64), NULLABLE, NO server default (NULL = "unknown" for
|
|
pre-phase rows; a migration cannot backfill — it cannot see the
|
|
source files on disk) — and is UNINDEXED (the fast path compares it
|
|
in Python after the unique ``(source, path)`` lookup — internal
|
|
bookkeeping, never a query surface) while the 0024 ``documents``
|
|
schema survives — in particular ``content_hash`` stays NOT NULL and
|
|
INDEXED (the retriever's dedupe/caching identity, untouched);
|
|
* rows inserted while the DB is at 0024 backfill ``file_hash`` to NULL
|
|
— the pre-phase shape, no backfill, no default;
|
|
* the ORM contract agrees: a freshly inserted ``Document`` with no
|
|
``file_hash`` reads ``file_hash is None`` through a fresh session
|
|
(the pre-phase shape), and an explicit digest round-trips;
|
|
* downgrade to 0024 → the column is GONE (A13) while the row + its
|
|
content survive; upgrade back to 0025 → the column is back
|
|
(round-trip).
|
|
|
|
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
|
fails or the process is interrupted.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import uuid
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from alembic.config import Config
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from alembic import command
|
|
from app.db import SessionLocal, db_available
|
|
from app.models import Document
|
|
|
|
SOURCE = "mig0025"
|
|
CONTENT = "content here"
|
|
CONTENT_HASH = hashlib.sha256(CONTENT.encode()).hexdigest()
|
|
FILE_HASH = hashlib.sha256(b"raw source bytes").hexdigest()
|
|
|
|
|
|
@pytest.fixture()
|
|
def alembic(db: Session) -> Iterator[Config]:
|
|
"""Real Alembic config bound to the dev DB (URL from app settings).
|
|
|
|
Starts at head (repairs an interrupted earlier run); teardown
|
|
upgrades to head no matter what happened, so the dev DB is never
|
|
left below head.
|
|
"""
|
|
if not db_available():
|
|
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
|
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
|
cfg.set_main_option("script_location", "alembic")
|
|
command.upgrade(cfg, "head")
|
|
try:
|
|
yield cfg
|
|
finally:
|
|
# Release the test session's open transaction BEFORE the repair
|
|
# DDL: an idle-in-transaction SELECT holds an ACCESS SHARE lock
|
|
# on ``documents``, which would deadlock the repair's
|
|
# ``ALTER TABLE`` (0025) forever.
|
|
db.rollback()
|
|
command.upgrade(cfg, "head")
|
|
|
|
|
|
def _version(db: Session) -> str | None:
|
|
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
|
|
|
|
|
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
|
|
"""(data_type, is_nullable, column_default, char_max_length) for one
|
|
documents column."""
|
|
row = db.execute(
|
|
text(
|
|
"SELECT data_type, is_nullable, column_default, character_maximum_length"
|
|
" FROM information_schema.columns"
|
|
" WHERE table_name = 'documents' AND column_name = :c"
|
|
),
|
|
{"c": column},
|
|
).fetchone()
|
|
return tuple(row) if row is not None else None
|
|
|
|
|
|
def _index_covering(db: Session, column: str) -> str | None:
|
|
"""The name of any documents index whose definition mentions the
|
|
column (None = UNINDEXED)."""
|
|
return db.execute(
|
|
text(
|
|
"SELECT indexname FROM pg_indexes"
|
|
" WHERE tablename = 'documents' AND indexdef ILIKE :like"
|
|
),
|
|
{"like": f"%{column}%"},
|
|
).scalar()
|
|
|
|
|
|
def _insert_sql(db: Session, path: str) -> uuid.UUID:
|
|
"""Insert one documents row with the PRE-0025 column set (the 0024
|
|
shape — the file_hash column, when present, is omitted so a NULL
|
|
backfill is what the row reads)."""
|
|
doc_id = uuid.uuid4()
|
|
db.execute(
|
|
text(
|
|
"INSERT INTO documents (id, source, path, full_path, title, content,"
|
|
" content_hash) VALUES (:id, :s, :p, :fp, :t, :c, :h)"
|
|
),
|
|
{
|
|
"id": doc_id,
|
|
"s": SOURCE,
|
|
"p": path,
|
|
"fp": f"/tmp/{path}",
|
|
"t": f"Doc {path}",
|
|
"c": CONTENT,
|
|
"h": CONTENT_HASH,
|
|
},
|
|
)
|
|
db.commit()
|
|
return doc_id
|
|
|
|
|
|
def _delete(db: Session, doc_id: uuid.UUID) -> None:
|
|
db.execute(text("DELETE FROM documents WHERE id = :id"), {"id": doc_id})
|
|
db.commit()
|
|
|
|
|
|
def _assert_0024_schema_survives(db: Session) -> None:
|
|
"""The 0024 documents contract must survive the additive upgrade —
|
|
in particular ``content_hash`` stays NOT NULL and indexed (the
|
|
retriever's identity — untouched by phase 147)."""
|
|
content = _column(db, "content_hash")
|
|
assert content is not None and content[0] == "character varying" and content[1] == "NO", (
|
|
"documents.content_hash (0001) must keep its 0024 contract"
|
|
)
|
|
assert content[3] == 64, "documents.content_hash must keep String(64)"
|
|
assert (
|
|
_index_covering(db, "content_hash") is not None
|
|
), "the ix_documents_content_hash index must survive the upgrade"
|
|
|
|
indexed = _column(db, "indexed_at")
|
|
assert indexed is not None and indexed[0] == "timestamp with time zone", (
|
|
"documents.indexed_at (0001) must survive the upgrade"
|
|
)
|
|
created = _column(db, "created_at")
|
|
assert created is not None and created[1] == "NO", (
|
|
"documents.created_at (0020) must survive the upgrade"
|
|
)
|
|
manual = _column(db, "created_at_manual")
|
|
assert manual is not None and manual[0] == "boolean" and manual[1] == "NO", (
|
|
"documents.created_at_manual (0020) must survive the upgrade"
|
|
)
|
|
is_image = _column(db, "is_image")
|
|
assert is_image is not None and is_image[0] == "boolean" and is_image[1] == "NO", (
|
|
"documents.is_image (0022) must survive the upgrade"
|
|
)
|
|
image_path = _column(db, "image_path")
|
|
assert image_path is not None and image_path[1] == "YES", (
|
|
"documents.image_path (0022) must survive the upgrade"
|
|
)
|
|
image_ctx = _column(db, "image_context")
|
|
assert image_ctx is not None and image_ctx[1] == "YES", (
|
|
"documents.image_context (0023) must survive the upgrade"
|
|
)
|
|
# Match by definition, not name: the dev DB's ``documents`` indexes
|
|
# carry a one-off repair prefix (``documents_heal_new_…_*`` — a
|
|
# manual dev-DB heal, never a migration), so the name itself is not
|
|
# a stable pin.
|
|
uq = db.execute(
|
|
text(
|
|
"SELECT indexname FROM pg_indexes"
|
|
" WHERE tablename = 'documents'"
|
|
" AND indexdef LIKE 'CREATE UNIQUE INDEX %USING btree (source, path)'",
|
|
)
|
|
).scalar()
|
|
assert uq is not None, (
|
|
"the unique (source, path) index must survive the upgrade"
|
|
)
|
|
|
|
|
|
def test_upgrade_to_0025_adds_file_hash(db: Session, alembic: Config) -> None:
|
|
"""Upgrade 0024 → 0025: ``file_hash`` exists with the full contract
|
|
(VARCHAR(64), NULLABLE, NO server default — NULL = "unknown" for
|
|
pre-phase rows — and UNINDEXED: the fast path compares it in Python
|
|
after the unique ``(source, path)`` lookup), is ABSENT at 0024, and
|
|
every pre-0025 row backfills ``file_hash`` to NULL (no backfill
|
|
possible — a migration cannot see the source files on disk) — while
|
|
the 0024 table contract (incl. the INDEXED ``content_hash``)
|
|
survives."""
|
|
command.downgrade(alembic, "0024") # start from the pre-0025 state
|
|
assert _version(db) == "0024"
|
|
assert _column(db, "file_hash") is None, "file_hash must be absent at 0024"
|
|
|
|
pre_id = _insert_sql(db, "pre.md") # the 0024 column set
|
|
try:
|
|
command.upgrade(alembic, "0025")
|
|
assert _version(db) == "0025", "alembic_version must be at 0025"
|
|
|
|
col = _column(db, "file_hash")
|
|
assert col is not None, "documents.file_hash is missing"
|
|
assert col[0] == "character varying", "file_hash must be VARCHAR"
|
|
assert col[1] == "YES", "file_hash must be NULLABLE (NULL = unknown)"
|
|
assert col[2] is None, (
|
|
"file_hash must carry NO server default — NULL is the meaningful"
|
|
" pre-phase state"
|
|
)
|
|
assert col[3] == 64, "file_hash must be VARCHAR(64) — a sha256 hex digest"
|
|
assert _index_covering(db, "file_hash") is None, (
|
|
"file_hash must be UNINDEXED — it is compared in Python after the"
|
|
" unique (source, path) lookup, never a query surface"
|
|
)
|
|
|
|
# The pre-0025 row backfilled to NULL (the pre-phase shape — one
|
|
# full pipeline pass stamps it on the next sync).
|
|
assert db.execute(
|
|
text("SELECT content_hash, file_hash FROM documents WHERE id = :id"),
|
|
{"id": pre_id},
|
|
).fetchone() == (CONTENT_HASH, None), (
|
|
"every pre-0025 row must backfill file_hash to NULL — no"
|
|
" backfill, no default — while content_hash survives"
|
|
)
|
|
|
|
_assert_0024_schema_survives(db)
|
|
finally:
|
|
_delete(db, pre_id)
|
|
|
|
|
|
def test_orm_file_hash_round_trips(db: Session, alembic: Config) -> None:
|
|
"""The ORM contract agrees with the column contract: a freshly
|
|
inserted ``Document`` with NO ``file_hash`` reads ``file_hash is
|
|
None`` through a FRESH session (the pre-phase shape — NULL until the
|
|
first post-upgrade sync stamps it), and an explicit digest
|
|
round-trips the value."""
|
|
command.upgrade(alembic, "head")
|
|
row_stamped = Document(
|
|
source=SOURCE,
|
|
path="stamped.md",
|
|
full_path="/tmp/stamped.md",
|
|
title="stamped",
|
|
content=CONTENT,
|
|
content_hash=CONTENT_HASH,
|
|
file_hash=FILE_HASH,
|
|
)
|
|
row_unstamped = Document(
|
|
source=SOURCE,
|
|
path="unstamped.md",
|
|
full_path="/tmp/unstamped.md",
|
|
title="unstamped",
|
|
content=CONTENT,
|
|
content_hash=CONTENT_HASH,
|
|
)
|
|
db.add(row_stamped)
|
|
db.add(row_unstamped)
|
|
db.commit()
|
|
try:
|
|
with SessionLocal() as fresh:
|
|
reloaded_stamped = fresh.get(Document, row_stamped.id)
|
|
assert reloaded_stamped is not None, "the stamped row must be readable"
|
|
assert reloaded_stamped.file_hash == FILE_HASH, (
|
|
"the explicit file_hash must round-trip through the DB"
|
|
)
|
|
reloaded_unstamped = fresh.get(Document, row_unstamped.id)
|
|
assert reloaded_unstamped is not None, "the unstamped row must be readable"
|
|
assert reloaded_unstamped.file_hash is None, (
|
|
"a row created without file_hash must read NULL (the pre-phase"
|
|
" shape)"
|
|
)
|
|
assert reloaded_unstamped.content_hash == CONTENT_HASH, (
|
|
"content_hash must be untouched by the file_hash addition"
|
|
)
|
|
finally:
|
|
_delete(db, row_stamped.id)
|
|
_delete(db, row_unstamped.id)
|
|
|
|
|
|
def test_downgrade_to_0024_drops_the_column(db: Session, alembic: Config) -> None:
|
|
"""Downgrade 0025 → 0024: the file_hash column is gone (A13 — fully
|
|
reversible, the hash is re-computable from the source files) while
|
|
the row + its content (incl. the stamped file_hash value's sibling
|
|
``content_hash``) survive, and the rest of the 0024 table contract
|
|
is intact."""
|
|
command.upgrade(alembic, "head")
|
|
row = Document(
|
|
source=SOURCE,
|
|
path="down.md",
|
|
full_path="/tmp/down.md",
|
|
title="down",
|
|
content=CONTENT,
|
|
content_hash=CONTENT_HASH,
|
|
file_hash=FILE_HASH,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
try:
|
|
command.downgrade(alembic, "0024")
|
|
assert _version(db) == "0024"
|
|
assert _column(db, "file_hash") is None, "file_hash must be dropped"
|
|
|
|
surviving = db.execute(
|
|
text(
|
|
"SELECT source, path, content, content_hash, is_image, image_path,"
|
|
" image_context FROM documents WHERE id = :id"
|
|
),
|
|
{"id": row.id},
|
|
).fetchone()
|
|
assert surviving is not None and surviving[0] == SOURCE, (
|
|
"the row must survive the column drop"
|
|
)
|
|
assert surviving[1] == "down.md" and surviving[2] == CONTENT, (
|
|
"path + content must survive the drop"
|
|
)
|
|
assert surviving[3] == CONTENT_HASH, (
|
|
"content_hash (the retriever's identity) must survive the drop"
|
|
)
|
|
assert surviving[4] is False and surviving[5] is None and surviving[6] is None, (
|
|
"is_image + image_path + image_context must survive the drop"
|
|
)
|
|
assert _index_covering(db, "content_hash") is not None, (
|
|
"the content_hash index must survive the downgrade"
|
|
)
|
|
finally:
|
|
_delete(db, row.id)
|
|
# Repair: the fixture teardown re-upgrades to head.
|
|
|
|
|
|
def test_upgrade_round_trip_restores_the_column(db: Session, alembic: Config) -> None:
|
|
"""Downgrade to 0024, then upgrade back to 0025: ``file_hash`` is
|
|
back with the full contract (VARCHAR(64), NULLABLE, NO server
|
|
default, UNINDEXED), a row seeded at 0024 reads ``file_hash is
|
|
None`` (the pre-phase shape on the round-trip), and the 0024 schema
|
|
(incl. the indexed ``content_hash``) is intact."""
|
|
command.downgrade(alembic, "0024")
|
|
pre_id = _insert_sql(db, "round.md")
|
|
try:
|
|
command.upgrade(alembic, "0025")
|
|
assert _version(db) == "0025", "round-trip upgrade must land at 0025"
|
|
|
|
col = _column(db, "file_hash")
|
|
assert col is not None, "documents.file_hash must be back"
|
|
assert col[0] == "character varying", "file_hash must be VARCHAR after the round-trip"
|
|
assert col[1] == "YES", "file_hash must be NULLABLE after the round-trip"
|
|
assert col[2] is None, (
|
|
"file_hash must still carry NO server default after the round-trip"
|
|
)
|
|
assert col[3] == 64, "file_hash must be VARCHAR(64) after the round-trip"
|
|
assert _index_covering(db, "file_hash") is None, (
|
|
"file_hash must stay UNINDEXED after the round-trip"
|
|
)
|
|
|
|
assert db.execute(
|
|
text("SELECT file_hash FROM documents WHERE id = :id"),
|
|
{"id": pre_id},
|
|
).scalar() is None, (
|
|
"a row seeded at 0024 must read file_hash NULL after the round-trip"
|
|
)
|
|
|
|
_assert_0024_schema_survives(db)
|
|
finally:
|
|
_delete(db, pre_id)
|