From 2b7688bd752bc76736016dafee6070515f35c4d6 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 23 Jul 2026 02:18:11 +0000 Subject: [PATCH] fix(file-index): prune removed active assets on rebuild Treat the source registry as authoritative during file-index rebuilds while preserving staged uploads. This prevents deleted archived sources from remaining searchable. Tests: python3 -m unittest tests.test_file_assets tests.test_business_facts --- obsidian_wiki/file_assets.py | 34 ++++++++++++++++++++++------------ tests/test_file_assets.py | 26 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/obsidian_wiki/file_assets.py b/obsidian_wiki/file_assets.py index 74c261c..dafdca0 100644 --- a/obsidian_wiki/file_assets.py +++ b/obsidian_wiki/file_assets.py @@ -185,21 +185,31 @@ def rebuild_from_registry(vault_path: str | Path, *, db_path: Path | None = None if not registry_path.is_file(): raise FileNotFoundError(f"source registry not found: {registry_path}") - indexed = 0 + records: list[dict[str, Any]] = [] skipped = 0 + for raw in registry_path.read_text(encoding="utf-8").splitlines(): + if not raw.strip(): + continue + try: + record = json.loads(raw) + except json.JSONDecodeError: + skipped += 1 + continue + if record.get("record_type") == "source_archive": + records.append(record) + with connect(db_path) as conn: - for raw in registry_path.read_text(encoding="utf-8").splitlines(): - if not raw.strip(): - continue - try: - record = json.loads(raw) - except json.JSONDecodeError: - skipped += 1 - continue - if record.get("record_type") != "source_archive": - continue + # A rebuild is authoritative for archived sources. Preserve uploaded + # staging rows, but discard active rows that no longer have a registry + # record so deleted sources cannot remain searchable. + conn.execute( + "DELETE FROM file_assets_fts WHERE file_id IN (SELECT file_id FROM file_assets WHERE status = 'active')" + ) + conn.execute("DELETE FROM file_assets WHERE status = 'active'") + for record in records: upsert_asset(conn, asset_from_registry_record(record), status="active") - indexed += 1 + conn.execute("DELETE FROM file_assets_fts WHERE file_id NOT IN (SELECT file_id FROM file_assets)") + indexed = len(records) return RebuildResult(db_path=db_path, registry_path=registry_path, indexed=indexed, skipped=skipped) diff --git a/tests/test_file_assets.py b/tests/test_file_assets.py index 3ee4e57..27c76dd 100644 --- a/tests/test_file_assets.py +++ b/tests/test_file_assets.py @@ -55,6 +55,32 @@ class FileAssetIndexTests(unittest.TestCase): self.assertEqual(json.loads(rows[0]["pitch"]), ["0.5"]) self.assertEqual(json.loads(rows[0]["pin_count"]), ["18"]) + def test_rebuild_removes_active_records_removed_from_registry(self): + retained_id = "sha256:" + "c" * 64 + removed_id = "sha256:" + "d" * 64 + self._write_registry_record( + source_id=retained_id, + archive_path="_sources/archive/bjd/pdf/retained.pdf", + original_name="retained.pdf", + title="Retained source", + ) + self._write_registry_record( + source_id=removed_id, + archive_path="_sources/archive/bjd/xlsx/removed.xlsx", + original_name="removed.xlsx", + title="Removed source", + ) + rebuild_from_registry(self.vault, db_path=self.db_path) + + retained_record = json.loads(self.registry_path.read_text(encoding="utf-8").splitlines()[0]) + self.registry_path.write_text(json.dumps(retained_record) + "\n", encoding="utf-8") + rebuild_from_registry(self.vault, db_path=self.db_path) + + remaining = search_assets(self.vault, "retained", db_path=self.db_path, limit=5, read_only=True) + removed = search_assets(self.vault, "removed", db_path=self.db_path, limit=5, read_only=True) + self.assertEqual([row["file_id"] for row in remaining], [retained_id]) + self.assertEqual(removed, []) + def _write_registry_record(self, **fields): record = { "record_type": "source_archive",