Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/basic_memory/sync/sync_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,11 @@ async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optiona
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)

Expand Down Expand Up @@ -585,8 +590,11 @@ async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optiona
# 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}, "
Expand Down Expand Up @@ -659,13 +667,18 @@ async def sync_regular_file(self, path: str, new: bool = True) -> Tuple[Optional
# 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
Expand Down
3 changes: 1 addition & 2 deletions tests/markdown/test_entity_parser_error_handling.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"]
assert result.frontmatter.tags == ["test", "valid"]
84 changes: 84 additions & 0 deletions tests/sync/test_sync_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,90 @@ async def test_sync_preserves_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,
Expand Down
Loading