From 9f35f8b8d28eb168fbe6d570df3184297fcfd5c8 Mon Sep 17 00:00:00 2001 From: phernandez Date: Wed, 22 Jan 2025 22:37:34 -0600 Subject: [PATCH] fix unresolved links on sync --- src/basic_memory/cli/commands/sync.py | 1 + src/basic_memory/models/knowledge.py | 10 +- .../repository/relation_repository.py | 7 + src/basic_memory/services/relation_service.py | 1 + src/basic_memory/services/search_service.py | 2 +- src/basic_memory/sync/entity_sync_service.py | 11 +- src/basic_memory/sync/sync_service.py | 20 +- tests/conftest.py | 8 + tests/repository/test_entity_repository.py | 21 +- tests/repository/test_relation_repository.py | 33 +- tests/sync/test_entity_sync_service.py | 13 +- tests/sync/test_sync_service.py | 624 +++++++++++++++++- 12 files changed, 728 insertions(+), 23 deletions(-) diff --git a/src/basic_memory/cli/commands/sync.py b/src/basic_memory/cli/commands/sync.py index 0a042d86..06e2e250 100644 --- a/src/basic_memory/cli/commands/sync.py +++ b/src/basic_memory/cli/commands/sync.py @@ -70,6 +70,7 @@ async def get_sync_service(db_type=DatabaseType.FILESYSTEM): entity_sync_service=knowledge_sync_service, entity_parser=entity_parser, entity_repository=entity_repository, + relation_repository=relation_repository, search_service=search_service, ) diff --git a/src/basic_memory/models/knowledge.py b/src/basic_memory/models/knowledge.py index 5edba929..13041d9f 100644 --- a/src/basic_memory/models/knowledge.py +++ b/src/basic_memory/models/knowledge.py @@ -10,7 +10,6 @@ from sqlalchemy import ( Text, ForeignKey, UniqueConstraint, - text, DateTime, Index, JSON, @@ -185,7 +184,10 @@ class Relation(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE")) - to_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE")) + to_id: Mapped[int] = mapped_column( + Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True + ) + to_name: Mapped[str] = mapped_column(String) relation_type: Mapped[str] = mapped_column(String) context: Mapped[str] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime) @@ -206,7 +208,9 @@ class Relation(Base): return generate_permalink( f"{self.from_entity.permalink}/{self.relation_type}/{self.to_entity.permalink}" + if self.to_entity + else f"{self.from_entity.permalink}/{self.relation_type}" ) def __repr__(self) -> str: - return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, type='{self.relation_type}')" \ No newline at end of file + return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, type='{self.relation_type}')" diff --git a/src/basic_memory/repository/relation_repository.py b/src/basic_memory/repository/relation_repository.py index ea7ed5e7..5226f512 100644 --- a/src/basic_memory/repository/relation_repository.py +++ b/src/basic_memory/repository/relation_repository.py @@ -67,5 +67,12 @@ class RelationRepository(Repository[Relation]): async with db.scoped_session(self.session_maker) as session: await session.execute(delete(Relation).where(Relation.from_id == entity_id)) + async def find_unresolved_relations(self) -> Sequence[Relation]: + """Find all unresolved relations, where to_id is null.""" + query = select(Relation).filter(Relation.to_id.is_(None)) + result = await self.execute_query(query) + return result.scalars().all() + + def get_load_options(self) -> List[LoaderOption]: return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)] diff --git a/src/basic_memory/services/relation_service.py b/src/basic_memory/services/relation_service.py index 41fb46d3..79ea9f27 100644 --- a/src/basic_memory/services/relation_service.py +++ b/src/basic_memory/services/relation_service.py @@ -47,6 +47,7 @@ class RelationService(BaseService[RelationRepository]): relation = RelationModel( from_id=from_entity.id, to_id=to_entity.id, + to_name=to_entity.title, relation_type=rs.relation_type, context=rs.context, ) diff --git a/src/basic_memory/services/search_service.py b/src/basic_memory/services/search_service.py index d5b87dbe..ffa555f8 100644 --- a/src/basic_memory/services/search_service.py +++ b/src/basic_memory/services/search_service.py @@ -177,7 +177,7 @@ class SearchService: # Only index outgoing relations (ones defined in this file) for rel in entity.outgoing_relations: # Create descriptive title showing the relationship - relation_title = f"{rel.from_entity.title} → {rel.to_entity.title}" + relation_title = f"{rel.from_entity.title} → {rel.to_entity.title}" if rel.to_entity else f"{rel.from_entity.title}" await self._do_index( SearchIndexRow( diff --git a/src/basic_memory/sync/entity_sync_service.py b/src/basic_memory/sync/entity_sync_service.py index 22bcce2c..9c85943a 100644 --- a/src/basic_memory/sync/entity_sync_service.py +++ b/src/basic_memory/sync/entity_sync_service.py @@ -159,15 +159,16 @@ class EntitySyncService: rel.target, ) - # Look up target entity - if not target_entity: - logger.warning(f"Skipping relation in {file_path}: target not found: {rel.target}") - continue + # if the target is found, store the id + target_id = target_entity.id if target_entity else None + # if the target is found, store the title, otherwise add the target for a "forward link" + target_name = target_entity.title if target_entity else rel.target # Create the relation relation = Relation( from_id=db_entity.id, - to_id=target_entity.id, + to_id=target_id, + to_name=target_name, relation_type=rel.type, context=rel.context, ) diff --git a/src/basic_memory/sync/sync_service.py b/src/basic_memory/sync/sync_service.py index c75cce78..90eca1a5 100644 --- a/src/basic_memory/sync/sync_service.py +++ b/src/basic_memory/sync/sync_service.py @@ -6,7 +6,7 @@ from typing import Dict from loguru import logger from basic_memory.markdown import EntityParser, EntityMarkdown -from basic_memory.repository import EntityRepository +from basic_memory.repository import EntityRepository, RelationRepository from basic_memory.services.search_service import SearchService from basic_memory.sync import FileChangeScanner from basic_memory.sync.entity_sync_service import EntitySyncService @@ -27,12 +27,14 @@ class SyncService: entity_sync_service: EntitySyncService, entity_parser: EntityParser, entity_repository: EntityRepository, + relation_repository: RelationRepository, search_service: SearchService, ): self.scanner = scanner self.entity_sync_service = entity_sync_service self.entity_parser = entity_parser self.entity_repository = entity_repository + self.relation_repository = relation_repository self.search_service = search_service async def handle_entity_deletion(self, file_path: str): @@ -119,4 +121,20 @@ class SyncService: # Set final checksum to mark sync complete await self.entity_repository.update(entity.id, {"checksum": checksum}) + # Third pass: Try to resolve any forward references + logger.debug("Attempting to resolve forward references") + for relation in await self.relation_repository.find_unresolved_relations(): + target_entity = await self.entity_sync_service.link_resolver.resolve_link(relation.to_name) + # check we found a link that is not the source + if target_entity and target_entity.id != relation.from_id: + logger.debug(f"Resolved forward reference: {relation.to_name} -> {target_entity.permalink}") + await self.relation_repository.update(relation.id, { + "to_id": target_entity.id, + "to_name": target_entity.title # Update to actual title + }) + + # update search index + await self.search_service.index_entity(target_entity) + + return changes diff --git a/tests/conftest.py b/tests/conftest.py index 485bd1fc..5e68d3a2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -182,6 +182,7 @@ async def sync_service( file_change_scanner: FileChangeScanner, entity_parser: EntityParser, entity_repository: EntityRepository, + relation_repository: RelationRepository, search_service: SearchService, ) -> SyncService: """Create sync service for testing.""" @@ -189,6 +190,7 @@ async def sync_service( scanner=file_change_scanner, entity_sync_service=entity_sync_service, entity_repository=entity_repository, + relation_repository=relation_repository, entity_parser=entity_parser, search_service=search_service, ) @@ -263,6 +265,7 @@ async def full_entity(sample_entity, entity_repository): Relation( from_id=search_entity.id, to_id=sample_entity.id, + to_name=sample_entity.title, relation_type="out1", created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), @@ -270,6 +273,7 @@ async def full_entity(sample_entity, entity_repository): Relation( from_id=search_entity.id, to_id=sample_entity.id, + to_name=sample_entity.title, relation_type="out2", created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), @@ -359,6 +363,7 @@ async def test_graph( Relation( from_id=root.id, to_id=conn1.id, + to_name = conn1.title, relation_type="connects_to", created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), @@ -366,6 +371,7 @@ async def test_graph( Relation( from_id=conn1.id, to_id=conn2.id, + to_name=conn2.title, relation_type="connected_to", created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), @@ -374,6 +380,7 @@ async def test_graph( Relation( from_id=conn2.id, to_id=deep.id, + to_name=deep.title, relation_type="deep_connection", created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), @@ -382,6 +389,7 @@ async def test_graph( Relation( from_id=deep.id, to_id=deeper.id, + to_name=deeper.title, relation_type="deeper_connection", created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), diff --git a/tests/repository/test_entity_repository.py b/tests/repository/test_entity_repository.py index 2b4bed50..50aadf1c 100644 --- a/tests/repository/test_entity_repository.py +++ b/tests/repository/test_entity_repository.py @@ -65,6 +65,7 @@ async def related_results(session_maker): relation = Relation( from_id=source.id, to_id=target.id, + to_name=target.title, relation_type="connects_to", created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc), @@ -465,11 +466,23 @@ async def test_list_entities_with_related(entity_repository: EntityRepository, s # Create relations in both directions relations = [ # core -> db (depends_on) - Relation(from_id=core.id, to_id=dbe.id, relation_type="depends_on", created_at=datetime.now(timezone.utc), - updated_at=datetime.now(timezone.utc),), + Relation( + from_id=core.id, + to_id=dbe.id, + to_name="db_service", + relation_type="depends_on", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), # config -> core (configures) - Relation(from_id=config.id, to_id=core.id, relation_type="configures", created_at=datetime.now(timezone.utc), - updated_at=datetime.now(timezone.utc),), + Relation( + from_id=config.id, + to_id=core.id, + to_name="core_service", + relation_type="configures", + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ), ] session.add_all(relations) diff --git a/tests/repository/test_relation_repository.py b/tests/repository/test_relation_repository.py index a84c73d2..f64e3506 100644 --- a/tests/repository/test_relation_repository.py +++ b/tests/repository/test_relation_repository.py @@ -52,9 +52,9 @@ async def target_entity(session_maker): async def test_relations(session_maker, source_entity, target_entity): """Create test relations.""" relations = [ - Relation(from_id=source_entity.id, to_id=target_entity.id, relation_type="connects_to", created_at=datetime.now(timezone.utc), + Relation(from_id=source_entity.id, to_id=target_entity.id, to_name=target_entity.title, relation_type="connects_to", created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),), - Relation(from_id=source_entity.id, to_id=target_entity.id, relation_type="depends_on", created_at=datetime.now(timezone.utc), + Relation(from_id=source_entity.id, to_id=target_entity.id, to_name=target_entity.title, relation_type="depends_on", created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),), ] async with db.scoped_session(session_maker) as session: @@ -86,6 +86,7 @@ async def sample_relation( relation_data = { "from_id": sample_entity.id, "to_id": related_entity.id, + "to_name": related_entity.title, "relation_type": "test_relation", "context": "test-context", } @@ -101,18 +102,21 @@ async def multiple_relations( { "from_id": sample_entity.id, "to_id": related_entity.id, + "to_name": related_entity.title, "relation_type": "relation_one", "context": "context_one", }, { "from_id": sample_entity.id, "to_id": related_entity.id, + "to_name": related_entity.title, "relation_type": "relation_two", "context": "context_two", }, { "from_id": related_entity.id, "to_id": sample_entity.id, + "to_name": related_entity.title, "relation_type": "relation_one", "context": "context_three", }, @@ -128,6 +132,7 @@ async def test_create_relation( relation_data = { "from_id": sample_entity.id, "to_id": related_entity.id, + "to_name": related_entity.title, "relation_type": "test_relation", "context": "test-context", } @@ -147,6 +152,7 @@ async def test_create_relation_entity_does_not_exist( relation_data = { "from_id": "not_exist", "to_id": related_entity.id, + "to_name": related_entity.title, "relation_type": "test_relation", "context": "test-context", } @@ -187,6 +193,28 @@ async def test_find_by_type(relation_repository: RelationRepository, sample_rela assert relations[0].id == sample_relation.id +@pytest.mark.asyncio +async def test_find_unresolved_relations( + relation_repository: RelationRepository, sample_entity: Entity, related_entity: Entity +): + """Test creating a new relation""" + relation_data = { + "from_id": sample_entity.id, + "to_id": None, + "to_name": related_entity.title, + "relation_type": "test_relation", + "context": "test-context", + } + relation = await relation_repository.create(relation_data) + + assert relation.from_id == sample_entity.id + assert relation.to_id is None + + unresolved = await relation_repository.find_unresolved_relations() + assert len(unresolved) == 1 + assert unresolved[0].id == relation.id + + @pytest.mark.asyncio async def test_delete_by_fields_single_field( relation_repository: RelationRepository, multiple_relations: list[Relation] @@ -311,3 +339,4 @@ async def test_delete_nonexistent_relation(relation_repository): """Test deleting a relation that doesn't exist.""" result = await relation_repository.delete_by_fields(relation_type="nonexistent") assert result is False + diff --git a/tests/sync/test_entity_sync_service.py b/tests/sync/test_entity_sync_service.py index 2bc16529..873104db 100644 --- a/tests/sync/test_entity_sync_service.py +++ b/tests/sync/test_entity_sync_service.py @@ -110,6 +110,10 @@ async def test_update_entity_relations( entity_sync_service: EntitySyncService, test_markdown: EntityMarkdown ): """Test second pass relation updates.""" + + # add a forward link to the markdown (entity does not exist) + test_markdown.content.relations.append(MarkdownRelation(type="depends_on", target="concept/doesnt-exist")) + # Create main entity first entity = await entity_sync_service.create_entity_from_markdown("test.md", test_markdown) @@ -136,10 +140,10 @@ async def test_update_entity_relations( # Check relations assert updated is not None, "Entity should be updated" - assert len(updated.relations) == 2 + assert len(updated.relations) == 3 # Check relation details - relations = sorted(updated.relations, key=lambda r: r.relation_type) + relations = sorted(updated.relations, key=lambda r: r.id) assert relations[0].relation_type == "depends_on" assert relations[0].from_id == entity.id @@ -149,6 +153,11 @@ async def test_update_entity_relations( assert relations[1].from_id == entity.id assert relations[1].to_id == another_entity.id + assert relations[2].relation_type == "depends_on" + assert relations[2].from_id == entity.id + assert relations[2].to_id is None + assert relations[2].to_name == "concept/doesnt-exist" + @pytest.mark.asyncio async def test_two_pass_sync_flow( diff --git a/tests/sync/test_sync_service.py b/tests/sync/test_sync_service.py index 5470231f..ef7d1f7d 100644 --- a/tests/sync/test_sync_service.py +++ b/tests/sync/test_sync_service.py @@ -20,6 +20,57 @@ async def create_test_file(path: Path, content: str = "test content") -> None: path.write_text(content) +@pytest.mark.asyncio +async def test_forward_reference_resolution( + sync_service: SyncService, + test_config: ProjectConfig, + entity_service: EntityService, +): + """Test that forward references get resolved when target file is created.""" + project_dir = test_config.home + + # First create a file with a forward reference + source_content = """ +--- +type: knowledge +--- +# Source Document + +## Relations +- depends_on [[target-doc]] +""" + await create_test_file(project_dir / "source.md", source_content) + + # Initial sync - should create forward reference + await sync_service.sync(test_config.home) + + # Verify forward reference + source = await entity_service.get_by_permalink("source") + assert len(source.relations) == 1 + assert source.relations[0].to_id is None + assert source.relations[0].to_name == "target-doc" + + # Now create the target file + target_content = """ +--- +type: knowledge +--- +# Target Doc +Target content +""" + await create_test_file(project_dir / "target_doc.md", target_content) + + # Sync again - should resolve the reference + await sync_service.sync(test_config.home) + + # Verify reference is now resolved + source = await entity_service.get_by_permalink("source") + target = await entity_service.get_by_permalink("target-doc") + assert len(source.relations) == 1 + assert source.relations[0].to_id == target.id + assert source.relations[0].to_name == target.title + + @pytest.mark.asyncio async def test_sync_knowledge( sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService @@ -71,11 +122,63 @@ A test concept. test_concept = next(e for e in entities if e.permalink == "concept/test-concept") assert test_concept.entity_type == "knowledge" - # Verify relation was not created - # because file for related entity was not found + # Verify relation was created + # with forward link entity = await entity_service.get_by_permalink(test_concept.permalink) relations = entity.relations - assert len(relations) == 0 + assert len(relations) == 1 + assert relations[0].to_name == "concept/other" + + +@pytest.mark.asyncio +async def test_forward_reference_resolution( + sync_service: SyncService, + test_config: ProjectConfig, + entity_service: EntityService, +): + """Test that forward references get resolved when target file is created.""" + project_dir = test_config.home + + # First create a file with a forward reference + source_content = """ +--- +type: knowledge +--- +# Source Document + +## Relations +- depends_on [[target-doc]] +""" + await create_test_file(project_dir / "source.md", source_content) + + # Initial sync - should create forward reference + await sync_service.sync(test_config.home) + + # Verify forward reference + source = await entity_service.get_by_permalink("source") + assert len(source.relations) == 1 + assert source.relations[0].to_id is None + assert source.relations[0].to_name == "target-doc" + + # Now create the target file + target_content = """ +--- +type: knowledge +--- +# Target Doc +Target content +""" + await create_test_file(project_dir / "target_doc.md", target_content) + + # Sync again - should resolve the reference + await sync_service.sync(test_config.home) + + # Verify reference is now resolved + source = await entity_service.get_by_permalink("source") + target = await entity_service.get_by_permalink("target-doc") + assert len(source.relations) == 1 + assert source.relations[0].to_id == target.id + assert source.relations[0].to_name == target.title @pytest.mark.asyncio @@ -112,7 +215,60 @@ modified: 2024-01-01 "concept/depends-on-future" ) assert entity is not None - assert len(entity.relations) == 0 # Relations to nonexistent entities should be skipped + assert len(entity.relations) == 2 + assert entity.relations[0].to_name == "concept/not_created_yet" + assert entity.relations[1].to_name == "concept/also_future" + + +@pytest.mark.asyncio +async def test_forward_reference_resolution( + sync_service: SyncService, + test_config: ProjectConfig, + entity_service: EntityService, +): + """Test that forward references get resolved when target file is created.""" + project_dir = test_config.home + + # First create a file with a forward reference + source_content = """ +--- +type: knowledge +--- +# Source Document + +## Relations +- depends_on [[target-doc]] +""" + await create_test_file(project_dir / "source.md", source_content) + + # Initial sync - should create forward reference + await sync_service.sync(test_config.home) + + # Verify forward reference + source = await entity_service.get_by_permalink("source") + assert len(source.relations) == 1 + assert source.relations[0].to_id is None + assert source.relations[0].to_name == "target-doc" + + # Now create the target file + target_content = """ +--- +type: knowledge +--- +# Target Doc +Target content +""" + await create_test_file(project_dir / "target_doc.md", target_content) + + # Sync again - should resolve the reference + await sync_service.sync(test_config.home) + + # Verify reference is now resolved + source = await entity_service.get_by_permalink("source") + target = await entity_service.get_by_permalink("target-doc") + assert len(source.relations) == 1 + assert source.relations[0].to_id == target.id + assert source.relations[0].to_name == target.title @pytest.mark.asyncio @@ -189,6 +345,57 @@ modified: 2024-01-01 assert b_relation.to_id == entity_a.id +@pytest.mark.asyncio +async def test_forward_reference_resolution( + sync_service: SyncService, + test_config: ProjectConfig, + entity_service: EntityService, +): + """Test that forward references get resolved when target file is created.""" + project_dir = test_config.home + + # First create a file with a forward reference + source_content = """ +--- +type: knowledge +--- +# Source Document + +## Relations +- depends_on [[target-doc]] +""" + await create_test_file(project_dir / "source.md", source_content) + + # Initial sync - should create forward reference + await sync_service.sync(test_config.home) + + # Verify forward reference + source = await entity_service.get_by_permalink("source") + assert len(source.relations) == 1 + assert source.relations[0].to_id is None + assert source.relations[0].to_name == "target-doc" + + # Now create the target file + target_content = """ +--- +type: knowledge +--- +# Target Doc +Target content +""" + await create_test_file(project_dir / "target_doc.md", target_content) + + # Sync again - should resolve the reference + await sync_service.sync(test_config.home) + + # Verify reference is now resolved + source = await entity_service.get_by_permalink("source") + target = await entity_service.get_by_permalink("target-doc") + assert len(source.relations) == 1 + assert source.relations[0].to_id == target.id + assert source.relations[0].to_name == target.title + + @pytest.mark.asyncio async def test_sync_entity_duplicate_relations( sync_service: SyncService, test_config: ProjectConfig @@ -251,6 +458,57 @@ modified: 2024-01-01 assert relation_counts["uses"] == 1 +@pytest.mark.asyncio +async def test_forward_reference_resolution( + sync_service: SyncService, + test_config: ProjectConfig, + entity_service: EntityService, +): + """Test that forward references get resolved when target file is created.""" + project_dir = test_config.home + + # First create a file with a forward reference + source_content = """ +--- +type: knowledge +--- +# Source Document + +## Relations +- depends_on [[target-doc]] +""" + await create_test_file(project_dir / "source.md", source_content) + + # Initial sync - should create forward reference + await sync_service.sync(test_config.home) + + # Verify forward reference + source = await entity_service.get_by_permalink("source") + assert len(source.relations) == 1 + assert source.relations[0].to_id is None + assert source.relations[0].to_name == "target-doc" + + # Now create the target file + target_content = """ +--- +type: knowledge +--- +# Target Doc +Target content +""" + await create_test_file(project_dir / "target_doc.md", target_content) + + # Sync again - should resolve the reference + await sync_service.sync(test_config.home) + + # Verify reference is now resolved + source = await entity_service.get_by_permalink("source") + target = await entity_service.get_by_permalink("target-doc") + assert len(source.relations) == 1 + assert source.relations[0].to_id == target.id + assert source.relations[0].to_name == target.title + + @pytest.mark.asyncio async def test_sync_entity_with_invalid_category( sync_service: SyncService, test_config: ProjectConfig @@ -292,6 +550,57 @@ modified: 2024-01-01 assert "design" in categories +@pytest.mark.asyncio +async def test_forward_reference_resolution( + sync_service: SyncService, + test_config: ProjectConfig, + entity_service: EntityService, +): + """Test that forward references get resolved when target file is created.""" + project_dir = test_config.home + + # First create a file with a forward reference + source_content = """ +--- +type: knowledge +--- +# Source Document + +## Relations +- depends_on [[target-doc]] +""" + await create_test_file(project_dir / "source.md", source_content) + + # Initial sync - should create forward reference + await sync_service.sync(test_config.home) + + # Verify forward reference + source = await entity_service.get_by_permalink("source") + assert len(source.relations) == 1 + assert source.relations[0].to_id is None + assert source.relations[0].to_name == "target-doc" + + # Now create the target file + target_content = """ +--- +type: knowledge +--- +# Target Doc +Target content +""" + await create_test_file(project_dir / "target_doc.md", target_content) + + # Sync again - should resolve the reference + await sync_service.sync(test_config.home) + + # Verify reference is now resolved + source = await entity_service.get_by_permalink("source") + target = await entity_service.get_by_permalink("target-doc") + assert len(source.relations) == 1 + assert source.relations[0].to_id == target.id + assert source.relations[0].to_name == target.title + + @pytest.mark.asyncio async def test_sync_entity_with_order_dependent_relations( sync_service: SyncService, test_config: ProjectConfig @@ -378,6 +687,57 @@ modified: 2024-01-01 assert len(entity_c.incoming_relations) == 2 # A and B depend on C +@pytest.mark.asyncio +async def test_forward_reference_resolution( + sync_service: SyncService, + test_config: ProjectConfig, + entity_service: EntityService, +): + """Test that forward references get resolved when target file is created.""" + project_dir = test_config.home + + # First create a file with a forward reference + source_content = """ +--- +type: knowledge +--- +# Source Document + +## Relations +- depends_on [[target-doc]] +""" + await create_test_file(project_dir / "source.md", source_content) + + # Initial sync - should create forward reference + await sync_service.sync(test_config.home) + + # Verify forward reference + source = await entity_service.get_by_permalink("source") + assert len(source.relations) == 1 + assert source.relations[0].to_id is None + assert source.relations[0].to_name == "target-doc" + + # Now create the target file + target_content = """ +--- +type: knowledge +--- +# Target Doc +Target content +""" + await create_test_file(project_dir / "target_doc.md", target_content) + + # Sync again - should resolve the reference + await sync_service.sync(test_config.home) + + # Verify reference is now resolved + source = await entity_service.get_by_permalink("source") + target = await entity_service.get_by_permalink("target-doc") + assert len(source.relations) == 1 + assert source.relations[0].to_id == target.id + assert source.relations[0].to_name == target.title + + @pytest.mark.asyncio async def test_sync_empty_directories(sync_service: SyncService, test_config: ProjectConfig): """Test syncing empty directories.""" @@ -387,6 +747,57 @@ async def test_sync_empty_directories(sync_service: SyncService, test_config: Pr assert (test_config.home).exists() +@pytest.mark.asyncio +async def test_forward_reference_resolution( + sync_service: SyncService, + test_config: ProjectConfig, + entity_service: EntityService, +): + """Test that forward references get resolved when target file is created.""" + project_dir = test_config.home + + # First create a file with a forward reference + source_content = """ +--- +type: knowledge +--- +# Source Document + +## Relations +- depends_on [[target-doc]] +""" + await create_test_file(project_dir / "source.md", source_content) + + # Initial sync - should create forward reference + await sync_service.sync(test_config.home) + + # Verify forward reference + source = await entity_service.get_by_permalink("source") + assert len(source.relations) == 1 + assert source.relations[0].to_id is None + assert source.relations[0].to_name == "target-doc" + + # Now create the target file + target_content = """ +--- +type: knowledge +--- +# Target Doc +Target content +""" + await create_test_file(project_dir / "target_doc.md", target_content) + + # Sync again - should resolve the reference + await sync_service.sync(test_config.home) + + # Verify reference is now resolved + source = await entity_service.get_by_permalink("source") + target = await entity_service.get_by_permalink("target-doc") + assert len(source.relations) == 1 + assert source.relations[0].to_id == target.id + assert source.relations[0].to_name == target.title + + @pytest.mark.asyncio async def test_sync_file_modified_during_sync( sync_service: SyncService, test_config: ProjectConfig @@ -425,6 +836,57 @@ modified: 2024-01-01 assert doc.checksum is not None +@pytest.mark.asyncio +async def test_forward_reference_resolution( + sync_service: SyncService, + test_config: ProjectConfig, + entity_service: EntityService, +): + """Test that forward references get resolved when target file is created.""" + project_dir = test_config.home + + # First create a file with a forward reference + source_content = """ +--- +type: knowledge +--- +# Source Document + +## Relations +- depends_on [[target-doc]] +""" + await create_test_file(project_dir / "source.md", source_content) + + # Initial sync - should create forward reference + await sync_service.sync(test_config.home) + + # Verify forward reference + source = await entity_service.get_by_permalink("source") + assert len(source.relations) == 1 + assert source.relations[0].to_id is None + assert source.relations[0].to_name == "target-doc" + + # Now create the target file + target_content = """ +--- +type: knowledge +--- +# Target Doc +Target content +""" + await create_test_file(project_dir / "target_doc.md", target_content) + + # Sync again - should resolve the reference + await sync_service.sync(test_config.home) + + # Verify reference is now resolved + source = await entity_service.get_by_permalink("source") + target = await entity_service.get_by_permalink("target-doc") + assert len(source.relations) == 1 + assert source.relations[0].to_id == target.id + assert source.relations[0].to_name == target.title + + @pytest.mark.asyncio async def test_permalink_formatting( sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService @@ -468,6 +930,57 @@ Testing permalink generation. ), f"File {filename} should have permalink {expected_permalink}" +@pytest.mark.asyncio +async def test_forward_reference_resolution( + sync_service: SyncService, + test_config: ProjectConfig, + entity_service: EntityService, +): + """Test that forward references get resolved when target file is created.""" + project_dir = test_config.home + + # First create a file with a forward reference + source_content = """ +--- +type: knowledge +--- +# Source Document + +## Relations +- depends_on [[target-doc]] +""" + await create_test_file(project_dir / "source.md", source_content) + + # Initial sync - should create forward reference + await sync_service.sync(test_config.home) + + # Verify forward reference + source = await entity_service.get_by_permalink("source") + assert len(source.relations) == 1 + assert source.relations[0].to_id is None + assert source.relations[0].to_name == "target-doc" + + # Now create the target file + target_content = """ +--- +type: knowledge +--- +# Target Doc +Target content +""" + await create_test_file(project_dir / "target_doc.md", target_content) + + # Sync again - should resolve the reference + await sync_service.sync(test_config.home) + + # Verify reference is now resolved + source = await entity_service.get_by_permalink("source") + target = await entity_service.get_by_permalink("target-doc") + assert len(source.relations) == 1 + assert source.relations[0].to_id == target.id + assert source.relations[0].to_name == target.title + + @pytest.mark.asyncio async def test_handle_entity_deletion( test_graph, @@ -498,6 +1011,57 @@ async def test_handle_entity_deletion( +@pytest.mark.asyncio +async def test_forward_reference_resolution( + sync_service: SyncService, + test_config: ProjectConfig, + entity_service: EntityService, +): + """Test that forward references get resolved when target file is created.""" + project_dir = test_config.home + + # First create a file with a forward reference + source_content = """ +--- +type: knowledge +--- +# Source Document + +## Relations +- depends_on [[target-doc]] +""" + await create_test_file(project_dir / "source.md", source_content) + + # Initial sync - should create forward reference + await sync_service.sync(test_config.home) + + # Verify forward reference + source = await entity_service.get_by_permalink("source") + assert len(source.relations) == 1 + assert source.relations[0].to_id is None + assert source.relations[0].to_name == "target-doc" + + # Now create the target file + target_content = """ +--- +type: knowledge +--- +# Target Doc +Target content +""" + await create_test_file(project_dir / "target_doc.md", target_content) + + # Sync again - should resolve the reference + await sync_service.sync(test_config.home) + + # Verify reference is now resolved + source = await entity_service.get_by_permalink("source") + target = await entity_service.get_by_permalink("target-doc") + assert len(source.relations) == 1 + assert source.relations[0].to_id == target.id + assert source.relations[0].to_name == target.title + + @pytest.mark.asyncio async def test_sync_preserves_timestamps( sync_service: SyncService, @@ -544,6 +1108,57 @@ Testing file timestamps assert abs((file_entity.created_at.timestamp() - file_stats.st_ctime)) < 1 # Allow 1s difference assert abs((file_entity.updated_at.timestamp() - file_stats.st_mtime)) < 1 # Allow 1s difference +@pytest.mark.asyncio +async def test_forward_reference_resolution( + sync_service: SyncService, + test_config: ProjectConfig, + entity_service: EntityService, +): + """Test that forward references get resolved when target file is created.""" + project_dir = test_config.home + + # First create a file with a forward reference + source_content = """ +--- +type: knowledge +--- +# Source Document + +## Relations +- depends_on [[target-doc]] +""" + await create_test_file(project_dir / "source.md", source_content) + + # Initial sync - should create forward reference + await sync_service.sync(test_config.home) + + # Verify forward reference + source = await entity_service.get_by_permalink("source") + assert len(source.relations) == 1 + assert source.relations[0].to_id is None + assert source.relations[0].to_name == "target-doc" + + # Now create the target file + target_content = """ +--- +type: knowledge +--- +# Target Doc +Target content +""" + await create_test_file(project_dir / "target_doc.md", target_content) + + # Sync again - should resolve the reference + await sync_service.sync(test_config.home) + + # Verify reference is now resolved + source = await entity_service.get_by_permalink("source") + target = await entity_service.get_by_permalink("target-doc") + assert len(source.relations) == 1 + assert source.relations[0].to_id == target.id + assert source.relations[0].to_name == target.title + + @pytest.mark.asyncio async def test_file_move_updates_search_index( sync_service: SyncService, @@ -581,7 +1196,6 @@ Content for move test assert len(results) == 1 assert results[0].file_path == str(new_path.relative_to(project_dir)) - @pytest.mark.asyncio async def test_sync_null_checksum_cleanup( sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService