From 9e25bc14f200fc607be8ff58ef78456248475ed1 Mon Sep 17 00:00:00 2001 From: ningyixi Date: Sun, 18 Oct 2026 22:04:42 +0800 Subject: [PATCH] fix(indexing): preserve file created_at through index input adaptation build_index_input_files always set created_at=None, so the markdown parser fell back to datetime.now() for notes without frontmatter `created`. Local readers already supply storage creation time on IndexInputFile; keep it so full reindex and first-index create paths use the file timestamp (#1772). Signed-off-by: ningyixi --- .../indexing/input_file_adaptation.py | 21 ++++++- tests/indexing/test_input_file_adaptation.py | 60 ++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/basic_memory/indexing/input_file_adaptation.py b/src/basic_memory/indexing/input_file_adaptation.py index 7548e32e3..6c9793ce7 100644 --- a/src/basic_memory/indexing/input_file_adaptation.py +++ b/src/basic_memory/indexing/input_file_adaptation.py @@ -24,6 +24,16 @@ def checksum(self) -> str | None: def last_modified(self) -> datetime | None: """Current storage last-modified timestamp, when available.""" + @property + def created_at(self) -> datetime | None: + """Current storage creation timestamp, when available. + + Used as the parser fallback for notes that do not declare frontmatter + `created`. Callers that cannot provide a creation time may omit the + attribute or return None; adaptation then leaves IndexInputFile.created_at + as None and the parser falls back to now (#1772). + """ + @property def content(self) -> bytes | None: """Loaded file content, when available.""" @@ -36,6 +46,12 @@ def content_type(self, path: str) -> str | None: """Return the canonical content type for the file path.""" +def _loaded_created_at(file_info: LoadedIndexFile) -> datetime | None: + """Read creation time from a loaded payload without requiring every adapter to declare it.""" + created_at = getattr(file_info, "created_at", None) + return created_at if isinstance(created_at, datetime) else None + + def build_index_input_files( files: Mapping[str, LoadedIndexFile], *, @@ -49,7 +65,10 @@ def build_index_input_files( checksum=file_info.checksum, content_type=content_type_provider.content_type(path), last_modified=file_info.last_modified, - created_at=None, + # Preserve storage creation time when the loader supplied it. Hardcoding + # None forced the markdown parser's ctime fallback to datetime.now() on + # every full reindex for notes without frontmatter `created` (#1772). + created_at=_loaded_created_at(file_info), content=file_info.content, ) for path, file_info in files.items() diff --git a/tests/indexing/test_input_file_adaptation.py b/tests/indexing/test_input_file_adaptation.py index 003d9a937..e59f493da 100644 --- a/tests/indexing/test_input_file_adaptation.py +++ b/tests/indexing/test_input_file_adaptation.py @@ -1,9 +1,10 @@ """Tests for portable index input-file adaptation.""" from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from basic_memory.indexing.input_file_adaptation import build_index_input_files +from basic_memory.indexing.models import IndexInputFile @dataclass(frozen=True, slots=True) @@ -14,6 +15,17 @@ class LoadedFile: checksum: str | None last_modified: datetime | None content: bytes | None + created_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class LoadedFileWithoutCreatedAt: + """Legacy loader shape that never advertised creation time.""" + + size: int + checksum: str | None + last_modified: datetime | None + content: bytes | None class PathContentTypeProvider: @@ -26,7 +38,8 @@ def content_type(self, path: str) -> str: def test_build_index_input_files_derives_content_type_from_path() -> None: - last_modified = datetime.now() + last_modified = datetime.now(tz=timezone.utc) + created_at = datetime(2024, 3, 1, 9, 0, tzinfo=timezone.utc) input_files = build_index_input_files( { @@ -35,6 +48,7 @@ def test_build_index_input_files_derives_content_type_from_path() -> None: checksum="etag-note", last_modified=last_modified, content=b"# note", + created_at=created_at, ), "image.png": LoadedFile( size=3, @@ -47,11 +61,12 @@ def test_build_index_input_files_derives_content_type_from_path() -> None: ) assert input_files["note.md"].content_type == "text/markdown" - assert input_files["note.md"].created_at is None + assert input_files["note.md"].created_at == created_at assert input_files["note.md"].last_modified == last_modified assert input_files["note.md"].content == b"# note" assert input_files["image.png"].content_type == "application/octet-stream" assert input_files["image.png"].checksum == "etag-image" + assert input_files["image.png"].created_at is None def test_build_index_input_files_accepts_missing_checksum_and_content() -> None: @@ -70,3 +85,42 @@ def test_build_index_input_files_accepts_missing_checksum_and_content() -> None: assert input_files["empty.md"].checksum == "" assert input_files["empty.md"].content is None assert input_files["empty.md"].created_at is None + + +def test_build_index_input_files_preserves_index_input_file_created_at() -> None: + """Local readers already put created_at on IndexInputFile; adaptation must keep it (#1772).""" + created_at = datetime(2024, 3, 1, 9, 0, tzinfo=timezone.utc) + last_modified = datetime(2025, 6, 1, 9, 0, tzinfo=timezone.utc) + src = IndexInputFile( + path="notes/kept.md", + size=12, + checksum="etag-kept", + content_type="text/markdown", + last_modified=last_modified, + created_at=created_at, + content=b"# kept note", + ) + + input_files = build_index_input_files( + {"notes/kept.md": src}, + content_type_provider=PathContentTypeProvider(), + ) + + assert input_files["notes/kept.md"].created_at == created_at + assert input_files["notes/kept.md"].last_modified == last_modified + + +def test_build_index_input_files_tolerates_loaders_without_created_at() -> None: + input_files = build_index_input_files( + { + "legacy.md": LoadedFileWithoutCreatedAt( + size=4, + checksum="etag", + last_modified=None, + content=b"body", + ), + }, + content_type_provider=PathContentTypeProvider(), + ) + + assert input_files["legacy.md"].created_at is None