From 118032d97473212d5f29b628303133adc87a86e6 Mon Sep 17 00:00:00 2001 From: kert Date: Tue, 8 Sep 2026 13:51:12 -0400 Subject: [PATCH] fix(bib): tag/collection changes stamp updated_at (refs #615) --- src/bib/store.py | 26 +++++++- tests/bib/test_store_updated_at.py | 98 ++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 tests/bib/test_store_updated_at.py diff --git a/src/bib/store.py b/src/bib/store.py index 29fb778..4204977 100644 --- a/src/bib/store.py +++ b/src/bib/store.py @@ -210,6 +210,11 @@ class Store: self._sync_tags(item_id, new_tags) if new_collections is not None: self._sync_collections(item_id, new_collections) + if not fields: + # A tags/collections-only rewrite still changes the item + # as the indexer sees it (year:/project:/cms-rule: land in + # chunk metadata), and nothing else stamped the row. + self._stamp(key) con.commit() @@ -516,6 +521,19 @@ class Store: (row["id"], item_id), ) + def _stamp(self, item_key: str) -> None: + """Mark the item row as changed *now*. + + Tags carry indexed metadata (``year:``, ``project:``, + ``cms-rule:``), so a tag-only edit has to move ``updated_at`` or + the llm indexer's fingerprint can never see it (#615). + """ + self._con().execute( + "UPDATE items SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') " + "WHERE key = ?", + (item_key,), + ) + def add_tag(self, item_key: str, tag: str) -> None: """Add a single tag to an item.""" con = self._con() @@ -523,21 +541,25 @@ class Store: if row is None: raise KeyError(f"Item not found: {item_key}") tag_id = self._ensure_tag(tag) - con.execute( + cur = con.execute( "INSERT OR IGNORE INTO item_tags (item_id, tag_id) VALUES (?, ?)", (row["id"], tag_id), ) + if cur.rowcount: # re-tagging an already-tagged item changes nothing + self._stamp(item_key) con.commit() def remove_tag(self, item_key: str, tag: str) -> None: """Remove a tag from an item.""" con = self._con() - con.execute( + cur = con.execute( """DELETE FROM item_tags WHERE item_id = (SELECT id FROM items WHERE key = ?) AND tag_id = (SELECT id FROM tags WHERE name = ?)""", (item_key, tag), ) + if cur.rowcount: # the tag wasn't there — nothing changed + self._stamp(item_key) con.commit() def list_tags(self, *, namespace: str = "") -> list[dict[str, int]]: diff --git a/tests/bib/test_store_updated_at.py b/tests/bib/test_store_updated_at.py new file mode 100644 index 0000000..fe90603 --- /dev/null +++ b/tests/bib/test_store_updated_at.py @@ -0,0 +1,98 @@ +"""Tag/collection changes must stamp ``items.updated_at``. + +The llm indexer fingerprints an item as ``updated_at`` + its file stats, +so a tag-only edit (``year:``, ``project:``, ``cms-rule:`` — all of which +land in chunk metadata) is invisible to a re-index unless the row is +stamped. An *unchanged* upsert must still write nothing. +""" + +from __future__ import annotations + +from bib.item import Source +from bib.store import Store + +OLD = "2000-01-01T00:00:00Z" + + +def _store() -> Store: + return Store(":memory:", storage_dir="/tmp/nope") + + +def _item(**kw) -> Source: + it = Source(title="T", url="https://www.regulations.gov/comment/CMS-2026-2377-1") + it.abstract = kw.get("abstract", "body") + for t in kw.get("tags", ["a:1", "b:2"]): + it.add_tag(t) + return it + + +def _backdate(s: Store, key: str) -> None: + """updated_at has 1s granularity — backdate so a bump is observable.""" + s._con().execute("UPDATE items SET updated_at = ? WHERE key = ?", (OLD, key)) + s._con().commit() + + +def _updated_at(s: Store, key: str) -> str: + return ( + s._con() + .execute("SELECT updated_at FROM items WHERE key = ?", (key,)) + .fetchone()["updated_at"] + ) + + +def test_add_tag_bumps_updated_at(): + s = _store() + key = s.create(_item()) + _backdate(s, key) + s.add_tag(key, "year:2026") + assert _updated_at(s, key) != OLD + + +def test_re_adding_the_same_tag_does_not_bump(): + s = _store() + key = s.create(_item()) + s.add_tag(key, "year:2026") + _backdate(s, key) + s.add_tag(key, "year:2026") # already there — nothing changed + assert _updated_at(s, key) == OLD + + +def test_remove_tag_bumps_updated_at(): + s = _store() + key = s.create(_item(tags=["year:2026"])) + _backdate(s, key) + s.remove_tag(key, "year:2026") + assert _updated_at(s, key) != OLD + + +def test_removing_an_absent_tag_does_not_bump(): + s = _store() + key = s.create(_item()) + _backdate(s, key) + s.remove_tag(key, "nope:1") + assert _updated_at(s, key) == OLD + + +def test_update_with_only_tags_bumps_updated_at(): + s = _store() + key = s.create(_item()) + _backdate(s, key) + s.update(key, tags=["year:2026", "project:pfs"]) + assert _updated_at(s, key) != OLD + + +def test_update_with_only_collections_bumps_updated_at(): + s = _store() + key = s.create(_item()) + _backdate(s, key) + s.update(key, collections=[]) + assert _updated_at(s, key) != OLD + + +def test_unchanged_upsert_still_does_not_bump(): + s = _store() + key, _ = s.upsert_status(_item()) + _backdate(s, key) + key2, status = s.upsert_status(_item()) + assert (key2, status) == (key, "unchanged") + assert _updated_at(s, key) == OLD