From f7fa308b2085f77b782a553692aaea91f57f7345 Mon Sep 17 00:00:00 2001 From: phernandez Date: Fri, 12 Jun 2026 09:07:14 -0500 Subject: [PATCH] fix(core): preserve an existing database default when repairing a missing config default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #985 (the #974 fix by @rudi193-cmd), addressing the codex P2 raised on #987: when config's default_project has no database row but the database still holds a valid default of its own, promoting the just-added project would silently steal that default. The repair now repoints config at a surviving database default — matching synchronize_projects, which treats the database default as authoritative — and promotes the added project only when no usable database default exists (no default at all, or one unknown to config, which set_default_project rejects and reconciliation deletes). Also removes an unused ProjectConfig import from the #985 regression test (strict ruff failure; CI's --fix lint masked it). Refs #974 Signed-off-by: phernandez --- src/basic_memory/services/project_service.py | 38 ++++++++++++---- tests/services/test_project_service.py | 48 +++++++++++++++++++- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index c962d6c0..187d5019 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -261,14 +261,36 @@ async def add_project(self, name: str, path: str, set_default: bool = False) -> if config_default is not None: db_default_project = await self.repository.get_by_name(config_default) if db_default_project is None: - await self.repository.set_as_default(created_project.id) - self.config_manager.set_default_project(name) - logger.info( - "Promoted project '%s' to default because configured default '%s' " - "is missing from database", - name, - config_default, - ) + # Trigger: config names a default project that has no database row + # (the fresh-config wedge from issue #974). + # Why: synchronize_projects treats an existing database default as + # authoritative, so a surviving default must win over promoting + # the just-added project. set_default_project raises for names + # absent from config — and reconciliation deletes such rows — + # so a database default unknown to config cannot be repointed to. + # Outcome: config is repointed at a usable database default when one + # exists; otherwise the added project becomes the default. + db_default = await self.repository.get_default_project() + if ( + db_default is not None + and self.config_manager.get_project(db_default.name)[0] is not None + ): + self.config_manager.set_default_project(db_default.name) + logger.info( + "Repointed config default from missing '%s' at existing " + "database default '%s'", + config_default, + db_default.name, + ) + else: + await self.repository.set_as_default(created_project.id) + self.config_manager.set_default_project(name) + logger.info( + "Promoted project '%s' to default because configured default '%s' " + "is missing from database", + name, + config_default, + ) logger.info(f"Project '{name}' added at {resolved_path}") diff --git a/tests/services/test_project_service.py b/tests/services/test_project_service.py index 43942d07..20490cb1 100644 --- a/tests/services/test_project_service.py +++ b/tests/services/test_project_service.py @@ -440,7 +440,7 @@ async def test_add_project_promotes_when_config_default_missing_from_db( ): """Regression #974: config default exists only in config, not DB — promote on add.""" from basic_memory import config as config_module - from basic_memory.config import ProjectConfig, ProjectEntry + from basic_memory.config import ProjectEntry from basic_memory.markdown.entity_parser import EntityParser from basic_memory.markdown.markdown_processor import MarkdownProcessor from basic_memory.repository.project_repository import ProjectRepository @@ -480,6 +480,52 @@ async def test_add_project_promotes_when_config_default_missing_from_db( assert await repo.get_by_name("main") is None +@pytest.mark.asyncio +async def test_add_project_preserves_existing_db_default( + project_service: ProjectService, config_manager: ConfigManager, test_project +): + """The #974 repair must not steal an existing database default. + + Drift state: config's default_project names a project with no database row, + but the database still holds a valid default of its own. synchronize_projects + resolves this by trusting the database default, so add_project's repair must + repoint config at it rather than promoting the just-added project. + """ + config_default = config_manager.default_project + assert config_default is not None + + surviving_name = f"test-surviving-default-{os.urandom(4).hex()}" + added_name = f"test-no-steal-{os.urandom(4).hex()}" + with tempfile.TemporaryDirectory() as temp_dir: + surviving_path = str(Path(temp_dir) / "surviving") + os.makedirs(surviving_path, exist_ok=True) + + # A normally-added project that then becomes the database default, + # while config's default_project still names the fixture project. + await project_service.add_project(surviving_name, surviving_path) + surviving = await project_service.repository.get_by_name(surviving_name) + assert surviving is not None + await project_service.repository.set_as_default(surviving.id) + + # Wedge config: its named default loses its database row. + await project_service.repository.delete(test_project.id) + assert await project_service.get_project(config_default) is None + + added_path = str(Path(temp_dir) / "added") + os.makedirs(added_path, exist_ok=True) + await project_service.add_project(added_name, added_path) + + # The surviving database default wins: config is repointed at it and + # the newly added project is not promoted. + assert config_manager.default_project == surviving_name + db_default = await project_service.repository.get_default_project() + assert db_default is not None + assert db_default.name == surviving_name + added = await project_service.repository.get_by_name(added_name) + assert added is not None + assert added.is_default is not True + + @pytest.mark.asyncio async def test_add_project_default_parameter_omitted(project_service: ProjectService): """Test adding a project without set_default parameter defaults to False behavior."""