diff --git a/src/basic_memory/sync/sync_service.py b/src/basic_memory/sync/sync_service.py index 52f0c6b8..ec341958 100644 --- a/src/basic_memory/sync/sync_service.py +++ b/src/basic_memory/sync/sync_service.py @@ -547,6 +547,11 @@ class SyncService: file_content = await self._read_file_async(file_path) file_contains_frontmatter = has_frontmatter(file_content) + # Get file timestamps for tracking modification times + file_stats = self.file_service.file_stats(path) + created = datetime.fromtimestamp(file_stats.st_ctime).astimezone() + modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone() + # entity markdown will always contain front matter, so it can be used up create/update the entity entity_markdown = await self.entity_parser.parse_file(path) @@ -585,8 +590,11 @@ class SyncService: # after relation processing is complete final_checksum = await self._compute_checksum_async(path) - # set checksum - await self.entity_repository.update(entity.id, {"checksum": final_checksum}) + # Update checksum and timestamps from file system + # This ensures temporal ordering in search and recent activity uses actual file modification times + await self.entity_repository.update( + entity.id, {"checksum": final_checksum, "created_at": created, "updated_at": modified} + ) logger.debug( f"Markdown sync completed: path={path}, entity_id={entity.id}, " @@ -659,13 +667,18 @@ class SyncService: # Re-raise if it's a different integrity error raise else: + # Get file timestamps for updating modification time + file_stats = self.file_service.file_stats(path) + modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone() + entity = await self.entity_repository.get_by_file_path(path) if entity is None: # pragma: no cover logger.error(f"Entity not found for existing file, path={path}") raise ValueError(f"Entity not found for existing file: {path}") + # Update checksum and modification time from file system updated = await self.entity_repository.update( - entity.id, {"file_path": path, "checksum": checksum} + entity.id, {"file_path": path, "checksum": checksum, "updated_at": modified} ) if updated is None: # pragma: no cover diff --git a/tests/markdown/test_entity_parser_error_handling.py b/tests/markdown/test_entity_parser_error_handling.py index b8e85026..b607324f 100644 --- a/tests/markdown/test_entity_parser_error_handling.py +++ b/tests/markdown/test_entity_parser_error_handling.py @@ -1,7 +1,6 @@ """Tests for entity parser error handling (issues #184 and #185).""" import pytest -from pathlib import Path from textwrap import dedent from basic_memory.markdown.entity_parser import EntityParser @@ -213,4 +212,4 @@ async def test_parse_valid_file_still_works(tmp_path): assert result is not None assert result.frontmatter.title == "Valid File" assert result.frontmatter.type == "knowledge" - assert result.frontmatter.tags == ["test", "valid"] \ No newline at end of file + assert result.frontmatter.tags == ["test", "valid"] diff --git a/tests/sync/test_sync_service.py b/tests/sync/test_sync_service.py index 1a8d32a3..98f5d8d6 100644 --- a/tests/sync/test_sync_service.py +++ b/tests/sync/test_sync_service.py @@ -645,6 +645,90 @@ Testing file timestamps assert abs(entity_updated_epoch - file_stats.st_mtime) < tolerance # Allow tolerance difference +@pytest.mark.asyncio +async def test_sync_updates_timestamps_on_file_modification( + sync_service: SyncService, + project_config: ProjectConfig, + entity_service: EntityService, +): + """Test that sync updates entity timestamps when files are modified. + + This test specifically validates that when an existing file is modified and re-synced, + the updated_at timestamp in the database reflects the file's actual modification time, + not the database operation time. This is critical for accurate temporal ordering in + search and recent_activity queries. + """ + import time + + project_dir = project_config.home + + # Create initial file + initial_content = """ +--- +type: knowledge +--- +# Test File +Initial content for timestamp test +""" + file_path = project_dir / "timestamp_test.md" + await create_test_file(file_path, initial_content) + + # Initial sync + await sync_service.sync(project_config.home) + + # Get initial entity and timestamps + entity_before = await entity_service.get_by_permalink("timestamp-test") + initial_updated_at = entity_before.updated_at + + # Wait a bit to ensure filesystem timestamp changes + time.sleep(0.1) + + # Modify the file content + modified_content = """ +--- +type: knowledge +--- +# Test File +Modified content for timestamp test + +## Observations +- [test] This was modified +""" + file_path.write_text(modified_content) + + # Wait to ensure mtime is different + time.sleep(0.1) + + # Get the file's modification time after our changes + file_stats_after_modification = file_path.stat() + + # Re-sync the modified file + await sync_service.sync(project_config.home) + + # Get entity after re-sync + entity_after = await entity_service.get_by_permalink("timestamp-test") + + # Verify that updated_at changed + assert entity_after.updated_at != initial_updated_at, ( + "updated_at should change when file is modified" + ) + + # Verify that updated_at matches the file's modification time, not db operation time + entity_updated_epoch = entity_after.updated_at.timestamp() + file_mtime = file_stats_after_modification.st_mtime + + # Allow 2s difference on Windows due to filesystem timing precision + tolerance = 2 if os.name == "nt" else 1 + assert abs(entity_updated_epoch - file_mtime) < tolerance, ( + f"Entity updated_at ({entity_after.updated_at}) should match file mtime " + f"({datetime.fromtimestamp(file_mtime)}) within {tolerance}s tolerance" + ) + + # Verify the content was actually updated + assert len(entity_after.observations) == 1 + assert entity_after.observations[0].content == "This was modified" + + @pytest.mark.asyncio async def test_file_move_updates_search_index( sync_service: SyncService,