diff --git a/src/basic_memory/indexing/note_content_reconciler.py b/src/basic_memory/indexing/note_content_reconciler.py index 50618d5d0..e7dc761ea 100644 --- a/src/basic_memory/indexing/note_content_reconciler.py +++ b/src/basic_memory/indexing/note_content_reconciler.py @@ -27,7 +27,8 @@ NoteContentSource, NoteContentWriteStatus, ObservedNoteContent, - plan_note_content_reconciliation, + bootstrap_note_content_plan, + plan_existing_note_content_reconciliation, ) from basic_memory.models import NoteContent from basic_memory.repository import NoteContentRepository @@ -350,16 +351,14 @@ async def reconcile( return NoteContentReconciliationResult.stale() if note_content is None: - plan = plan_note_content_reconciliation(None, observed) - if not isinstance(plan, NoteContentBootstrap): - raise RuntimeError("Missing note_content must bootstrap reconciliation") + bootstrap = bootstrap_note_content_plan(observed=observed) try: await self._note_content_repository.create( session, - note_content_from_bootstrap(entity.id, plan), + note_content_from_bootstrap(entity.id, bootstrap), ) - return NoteContentReconciliationResult.current(plan.db_version) + return NoteContentReconciliationResult.current(bootstrap.db_version) except IntegrityError: # Concurrent repair/index workers can both observe a missing row before # one wins the insert. Reload the winner and let normal reconciliation @@ -386,12 +385,10 @@ async def reconcile( # the row between our read and write is not silently reverted. expected_db_version = int(note_content.db_version) current_state = note_content_state_from_model(note_content) - plan = plan_note_content_reconciliation( - current_state, - observed, + plan = plan_existing_note_content_reconciliation( + current=current_state, + observed=observed, ) - if isinstance(plan, NoteContentBootstrap): - raise RuntimeError("Existing note_content cannot bootstrap reconciliation") if isinstance(plan, NoteContentReconciliationDeferred): logger.debug( "Deferred note_content file promotion for entity {}: " diff --git a/src/basic_memory/indexing/note_content_reconciliation.py b/src/basic_memory/indexing/note_content_reconciliation.py index 7e62dff89..c2c9dd5f9 100644 --- a/src/basic_memory/indexing/note_content_reconciliation.py +++ b/src/basic_memory/indexing/note_content_reconciliation.py @@ -191,9 +191,8 @@ class NoteContentPromoted: last_materialization_attempt_at: datetime | None -type NoteContentReconciliationPlan = ( - NoteContentBootstrap - | NoteContentFileSynced +type ExistingNoteContentReconciliationPlan = ( + NoteContentFileSynced | NoteContentFileObserved | NoteContentReconciliationDeferred | NoteContentPromoted @@ -212,10 +211,28 @@ def note_content_matches_accepted_version( return current.db_version == accepted.db_version and current.db_checksum == accepted.db_checksum -def plan_note_content_reconciliation( - current: NoteContentState | None, +def bootstrap_note_content_plan(*, observed: ObservedNoteContent) -> NoteContentBootstrap: + """Seed db_version=1/file_version=1 synced state for a note with no stored content row.""" + return NoteContentBootstrap( + markdown_content=observed.markdown_content, + db_version=1, + db_checksum=observed.checksum, + file_version=1, + file_checksum=observed.checksum, + file_write_status="synced", + last_source=observed.source, + updated_at=observed.observed_at, + file_updated_at=observed.observed_at, + last_materialization_error=None, + last_materialization_attempt_at=None, + ) + + +def plan_existing_note_content_reconciliation( + *, + current: NoteContentState, observed: ObservedNoteContent, -) -> NoteContentReconciliationPlan: +) -> ExistingNoteContentReconciliationPlan: """Choose the note_content write needed to converge one observed file. The same rule must apply everywhere: @@ -224,21 +241,6 @@ def plan_note_content_reconciliation( - an unrelated observation can advance DB only from fully synchronized state - otherwise: defer until the accepted DB/file lineage is stable """ - if current is None: - return NoteContentBootstrap( - markdown_content=observed.markdown_content, - db_version=1, - db_checksum=observed.checksum, - file_version=1, - file_checksum=observed.checksum, - file_write_status="synced", - last_source=observed.source, - updated_at=observed.observed_at, - file_updated_at=observed.observed_at, - last_materialization_error=None, - last_materialization_attempt_at=None, - ) - if observed.checksum == current.db_checksum: return NoteContentFileSynced( markdown_content=observed.markdown_content, diff --git a/tests/indexing/test_note_content_reconciliation.py b/tests/indexing/test_note_content_reconciliation.py index d28d4f12f..7bbcd3030 100644 --- a/tests/indexing/test_note_content_reconciliation.py +++ b/tests/indexing/test_note_content_reconciliation.py @@ -18,9 +18,10 @@ NoteContentReconciliationResult, NoteContentState, ObservedNoteContent, + bootstrap_note_content_plan, + plan_existing_note_content_reconciliation, plan_note_content_materialization_publish, plan_note_content_materialization_status, - plan_note_content_reconciliation, ) @@ -45,7 +46,7 @@ def _observed(checksum: str = "observed-checksum") -> ObservedNoteContent: def test_plan_bootstraps_missing_note_content() -> None: - plan = plan_note_content_reconciliation(None, _observed()) + plan = bootstrap_note_content_plan(observed=_observed()) assert plan == NoteContentBootstrap( markdown_content="# Observed\n", @@ -63,14 +64,14 @@ def test_plan_bootstraps_missing_note_content() -> None: def test_plan_marks_file_synced_when_observed_checksum_matches_db() -> None: - plan = plan_note_content_reconciliation( - NoteContentState( + plan = plan_existing_note_content_reconciliation( + current=NoteContentState( db_version=7, db_checksum="db-checksum", file_version=6, file_checksum="old-file-checksum", ), - _observed("db-checksum"), + observed=_observed("db-checksum"), ) assert plan == NoteContentFileSynced( @@ -85,14 +86,14 @@ def test_plan_marks_file_synced_when_observed_checksum_matches_db() -> None: def test_plan_refreshes_file_observation_when_db_is_ahead() -> None: - plan = plan_note_content_reconciliation( - NoteContentState( + plan = plan_existing_note_content_reconciliation( + current=NoteContentState( db_version=9, db_checksum="new-db-checksum", file_version=8, file_checksum="old-file-checksum", ), - _observed("old-file-checksum"), + observed=_observed("old-file-checksum"), ) assert plan == NoteContentFileObserved( @@ -103,30 +104,30 @@ def test_plan_refreshes_file_observation_when_db_is_ahead() -> None: def test_plan_defers_external_file_change_while_file_state_is_not_synced() -> None: - plan = plan_note_content_reconciliation( - NoteContentState( + plan = plan_existing_note_content_reconciliation( + current=NoteContentState( db_version=3, db_checksum="db-checksum", file_version=5, file_checksum="materialized-file-checksum", file_write_status="pending", ), - _observed("external-change-checksum"), + observed=_observed("external-change-checksum"), ) assert plan == NoteContentReconciliationDeferred() def test_plan_promotes_external_file_change_from_synced_state() -> None: - plan = plan_note_content_reconciliation( - NoteContentState( + plan = plan_existing_note_content_reconciliation( + current=NoteContentState( db_version=5, db_checksum="db-checksum", file_version=5, file_checksum="db-checksum", file_write_status="synced", ), - _observed("external-change-checksum"), + observed=_observed("external-change-checksum"), ) assert plan == NoteContentPromoted(