From c1699df68a393c7382d5c124937b48a0cd9b449b Mon Sep 17 00:00:00 2001 From: Drew Cain Date: Tue, 7 Apr 2026 08:43:13 -0500 Subject: [PATCH] fix(core): preserve external_id during entity upsert on re-index Force full re-index was generating new external_id UUIDs for every entity, breaking public share links that reference entities by their stable external_id. The upsert logic now preserves the existing external_id when resolving file_path conflicts. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Drew Cain --- .../repository/entity_repository.py | 3 ++ .../test_entity_repository_upsert.py | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/basic_memory/repository/entity_repository.py b/src/basic_memory/repository/entity_repository.py index 244bad8c..25d54632 100644 --- a/src/basic_memory/repository/entity_repository.py +++ b/src/basic_memory/repository/entity_repository.py @@ -388,6 +388,9 @@ async def upsert_entity(self, entity: Entity) -> Entity: # Use merge to avoid session state conflicts # Set the ID to update existing entity entity.id = existing_entity.id + # Preserve the stable external_id so that external references + # (e.g. public share links) survive re-indexing + entity.external_id = existing_entity.external_id # Ensure observations reference the correct entity_id for obs in entity.observations: diff --git a/tests/repository/test_entity_repository_upsert.py b/tests/repository/test_entity_repository_upsert.py index 351179d3..c50999b9 100644 --- a/tests/repository/test_entity_repository_upsert.py +++ b/tests/repository/test_entity_repository_upsert.py @@ -70,6 +70,50 @@ async def test_upsert_entity_same_file_update(entity_repository: EntityRepositor assert result2.file_path == "test/test-entity.md" +@pytest.mark.asyncio +async def test_upsert_entity_preserves_external_id(entity_repository: EntityRepository): + """Test that upserting an entity with the same file_path preserves the original external_id. + + Trigger: force full re-index creates a new Entity model (with a fresh UUID) + for a file that already has a database record + Why: external_id is used by public share links — if it changes, shares break + Outcome: the original external_id survives the upsert + """ + # Create initial entity + entity1 = Entity( + project_id=entity_repository.project_id, + title="Shared Note", + note_type="note", + permalink="test/shared-note", + file_path="test/shared-note.md", + content_type="text/markdown", + external_id="original-stable-id", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + result1 = await entity_repository.upsert_entity(entity1) + assert result1.external_id == "original-stable-id" + + # Simulate re-index: new Entity model with a DIFFERENT external_id + entity2 = Entity( + project_id=entity_repository.project_id, + title="Shared Note (updated)", + note_type="note", + permalink="test/shared-note", + file_path="test/shared-note.md", + content_type="text/markdown", + external_id="newly-generated-uuid", # would break share links + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + result2 = await entity_repository.upsert_entity(entity2) + + # ID preserved, title updated, external_id stable + assert result2.id == result1.id + assert result2.title == "Shared Note (updated)" + assert result2.external_id == "original-stable-id" + + @pytest.mark.asyncio async def test_upsert_entity_permalink_conflict_different_file(entity_repository: EntityRepository): """Test upserting an entity with permalink conflict but different file_path."""