From 3bbee7eed0ff214306d3aec017331cdb4f73d175 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 22 Apr 2026 15:05:40 -0500 Subject: [PATCH] feat(database): update trim_announces_for_aspect to protect favourited destinations and saved contacts from deletion --- meshchatx/src/backend/database/announces.py | 25 +++- tests/backend/test_announce_dao_trim.py | 144 ++++++++++++++++---- 2 files changed, 142 insertions(+), 27 deletions(-) diff --git a/meshchatx/src/backend/database/announces.py b/meshchatx/src/backend/database/announces.py index 16f545a..125f59d 100644 --- a/meshchatx/src/backend/database/announces.py +++ b/meshchatx/src/backend/database/announces.py @@ -50,7 +50,15 @@ class AnnounceDAO: self.provider.execute(query, params) def trim_announces_for_aspect(self, aspect, max_rows): - """Delete oldest rows for this aspect until at most max_rows remain.""" + """Delete oldest rows for this aspect until at most max_rows remain. + + Announces that correspond to a favourited destination or to a saved + contact are considered protected and are never deleted by this trim, + even if the total count exceeds ``max_rows``. This prevents purging + of announces (and the path/identity context they provide) for + favourited NomadNet nodes and for messaging contacts when storage + limits are enforced. + """ if max_rows < 1 or not aspect: return row = self.provider.fetchone( @@ -64,8 +72,19 @@ class AnnounceDAO: self.provider.execute( """ DELETE FROM announces WHERE id IN ( - SELECT id FROM announces WHERE aspect = ? - ORDER BY updated_at ASC, id ASC + SELECT a.id FROM announces a + WHERE a.aspect = ? + AND NOT EXISTS ( + SELECT 1 FROM favourite_destinations f + WHERE f.destination_hash = a.destination_hash + ) + AND NOT EXISTS ( + SELECT 1 FROM contacts c + WHERE c.remote_identity_hash = a.identity_hash + OR c.lxmf_address = a.destination_hash + OR c.lxst_address = a.destination_hash + ) + ORDER BY a.updated_at ASC, a.id ASC LIMIT ? ) """, diff --git a/tests/backend/test_announce_dao_trim.py b/tests/backend/test_announce_dao_trim.py index 36313ea..d78c6b2 100644 --- a/tests/backend/test_announce_dao_trim.py +++ b/tests/backend/test_announce_dao_trim.py @@ -7,12 +7,12 @@ from meshchatx.src.backend.database import Database from meshchatx.src.backend.database.provider import DatabaseProvider -def _insert(db, dest_hex, aspect, updated_order): +def _insert(db, dest_hex, aspect, updated_order, identity_hex=None): db.announces.upsert_announce( { "destination_hash": dest_hex, "aspect": aspect, - "identity_hash": "a" * 32, + "identity_hash": identity_hex or ("a" * 32), "identity_public_key": "cHVibmtleQ==", "app_data": None, "rssi": None, @@ -26,14 +26,37 @@ def _insert(db, dest_hex, aspect, updated_order): ) +def _new_db(): + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + path = f.name + db = Database(path) + db.initialize() + return db, path + + +def _cleanup(db, path): + if db is not None: + try: + db.close() + except Exception: + pass + DatabaseProvider._instance = None + if path: + try: + os.unlink(path) + except OSError: + pass + for suffix in ("-wal", "-shm"): + try: + os.unlink(path + suffix) + except OSError: + pass + + def test_trim_announces_for_aspect_drops_oldest(): - path = None - db = None + db = path = None try: - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: - path = f.name - db = Database(path) - db.initialize() + db, path = _new_db() aspect = "lxmf.delivery" _insert(db, "01" * 16, aspect, "2000-01-01T00:00:00Z") _insert(db, "02" * 16, aspect, "2000-01-02T00:00:00Z") @@ -43,19 +66,92 @@ def test_trim_announces_for_aspect_drops_oldest(): hashes = {r["destination_hash"] for r in rows} assert hashes == {"03" * 16, "02" * 16} finally: - if db is not None: - try: - db.close() - except Exception: - pass - DatabaseProvider._instance = None - if path: - try: - os.unlink(path) - except OSError: - pass - for suffix in ("-wal", "-shm"): - try: - os.unlink(path + suffix) - except OSError: - pass + _cleanup(db, path) + + +def test_trim_preserves_favourited_destination(): + """Favourited NomadNet/destination announces must survive aspect trim.""" + db = path = None + try: + db, path = _new_db() + aspect = "nomadnetwork.node" + + _insert(db, "01" * 16, aspect, "2000-01-01T00:00:00Z") + _insert(db, "02" * 16, aspect, "2000-01-02T00:00:00Z") + _insert(db, "03" * 16, aspect, "2000-01-03T00:00:00Z") + _insert(db, "04" * 16, aspect, "2000-01-04T00:00:00Z") + + db.announces.upsert_favourite("01" * 16, "Favourite Node", aspect) + + db.announces.trim_announces_for_aspect(aspect, 2) + + rows = db.announces.get_announces(aspect=aspect) + hashes = {r["destination_hash"] for r in rows} + + assert "01" * 16 in hashes, "favourited announce was wrongly trimmed" + assert "04" * 16 in hashes, "newest announce should be retained" + assert "02" * 16 not in hashes + assert "03" * 16 not in hashes + finally: + _cleanup(db, path) + + +def test_trim_preserves_contact_announce_by_identity_hash(): + """Announces tied to a saved contact via identity hash must not be dropped.""" + db = path = None + try: + db, path = _new_db() + aspect = "lxmf.delivery" + + contact_identity = "b" * 32 + + _insert(db, "01" * 16, aspect, "2000-01-01T00:00:00Z", contact_identity) + _insert(db, "02" * 16, aspect, "2000-01-02T00:00:00Z") + _insert(db, "03" * 16, aspect, "2000-01-03T00:00:00Z") + _insert(db, "04" * 16, aspect, "2000-01-04T00:00:00Z") + + db.contacts.add_contact( + name="Friend", + remote_identity_hash=contact_identity, + ) + + db.announces.trim_announces_for_aspect(aspect, 2) + + rows = db.announces.get_announces(aspect=aspect) + hashes = {r["destination_hash"] for r in rows} + + assert "01" * 16 in hashes, "contact-linked announce was wrongly trimmed" + assert "04" * 16 in hashes + finally: + _cleanup(db, path) + + +def test_trim_preserves_contact_announce_by_lxmf_address(): + """Announces matching contacts.lxmf_address must be retained.""" + db = path = None + try: + db, path = _new_db() + aspect = "lxmf.delivery" + + protected_dest = "01" * 16 + + _insert(db, protected_dest, aspect, "2000-01-01T00:00:00Z") + _insert(db, "02" * 16, aspect, "2000-01-02T00:00:00Z") + _insert(db, "03" * 16, aspect, "2000-01-03T00:00:00Z") + _insert(db, "04" * 16, aspect, "2000-01-04T00:00:00Z") + + db.contacts.add_contact( + name="Friend", + remote_identity_hash="c" * 32, + lxmf_address=protected_dest, + ) + + db.announces.trim_announces_for_aspect(aspect, 2) + + rows = db.announces.get_announces(aspect=aspect) + hashes = {r["destination_hash"] for r in rows} + + assert protected_dest in hashes + assert "04" * 16 in hashes + finally: + _cleanup(db, path)