diff --git a/src/basic_memory/services/project_deletes.py b/src/basic_memory/services/project_deletes.py index cab780511..de2a99b12 100644 --- a/src/basic_memory/services/project_deletes.py +++ b/src/basic_memory/services/project_deletes.py @@ -55,6 +55,26 @@ async def load_project_for_delete_acceptance( return result.scalars().one_or_none() +async def select_replacement_default( + session: AsyncSession, + *, + deleted_project_id: int, +) -> Project | None: + """Pick the active project that should inherit the default flag. + + Deleting the default project can't simply drop the flag: a workspace must + always resolve a default for project-less writes. The oldest remaining + active project wins so the promotion is deterministic. + """ + result = await session.execute( + select(Project) + .where(Project.is_active.is_(True), Project.id != deleted_project_id) + .order_by(Project.created_at.asc(), Project.id.asc()) + .limit(1) + ) + return result.scalars().first() + + async def reactivate_accepted_project( session_maker: async_sessionmaker[AsyncSession], *, @@ -90,12 +110,24 @@ async def delete_project( 404, f"Project with external_id '{request.project_external_id}' not found", ) + # A workspace must always resolve a default project for project-less + # writes, so the flag can't just vanish on delete. Cloud team + # workspaces hide the "set default" control (basic-memory-cloud + # #968), which previously left the default project undeletable there. + # Promote another active project instead of refusing; only block when + # nothing remains to inherit the flag. + replacement_default: Project | None = None if project.is_default: - raise ProjectDeleteAcceptanceError( - 400, - f"Cannot delete default project '{project.name}'. " - "Set another project as default first.", + replacement_default = await select_replacement_default( + session, + deleted_project_id=project.id, ) + if replacement_default is None: + raise ProjectDeleteAcceptanceError( + 400, + f"Cannot delete '{project.name}' because it is the only " + "project in the workspace.", + ) runtime_request = RuntimeProjectDeleteJobRequest( project_id=project.id, @@ -112,6 +144,11 @@ async def delete_project( is_default=project.is_default or False, ) project.is_active = False + # Hand the default flag to the promoted project in the same + # transaction so exactly one active default always exists. + if replacement_default is not None: + project.is_default = None + replacement_default.is_default = True await session.commit() try: diff --git a/tests/cloud/test_project_deletes.py b/tests/cloud/test_project_deletes.py index 9c1913bfa..8290338eb 100644 --- a/tests/cloud/test_project_deletes.py +++ b/tests/cloud/test_project_deletes.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import AsyncGenerator +from datetime import UTC, datetime import pytest import pytest_asyncio @@ -36,18 +37,26 @@ async def tenant_session_maker() -> AsyncGenerator[async_sessionmaker[AsyncSessi async def create_project( session_maker: async_sessionmaker[AsyncSession], *, + name: str = "Main", + permalink: str = "main", + external_id: str = "project-main", + path: str = "basic-memory", # None mirrors the nullable column default; the accepted response maps it to False. is_default: bool | None = None, + created_at: datetime | None = None, ) -> Project: async with session_maker() as session: project = Project( - name="Main", - path="basic-memory", - permalink="main", - external_id="project-main", + name=name, + path=path, + permalink=permalink, + external_id=external_id, is_active=True, is_default=is_default, ) + # Pin created_at when a test needs a deterministic promotion order. + if created_at is not None: + project.created_at = created_at session.add(project) await session.commit() return project @@ -118,9 +127,12 @@ async def test_project_delete_acceptance_soft_deletes_and_queues_runtime_request @pytest.mark.asyncio -async def test_project_delete_acceptance_rejects_default_project_before_soft_delete( +async def test_project_delete_acceptance_rejects_deleting_the_only_project( tenant_session_maker: async_sessionmaker[AsyncSession], ) -> None: + # The default project can only be deleted when another active project can + # inherit the flag. A sole project has no replacement, so the delete is + # rejected before any soft delete happens. project = await create_project(tenant_session_maker, is_default=True) enqueuer = RecordingProjectDeleteEnqueuer() service = ProjectDeleteAcceptanceService( @@ -140,12 +152,104 @@ async def test_project_delete_acceptance_rejects_default_project_before_soft_del stored_project = await session.get(Project, project.id) assert exc_info.value.status_code == 400 - assert "Cannot delete default project" in exc_info.value.detail + assert "only project" in exc_info.value.detail assert stored_project is not None assert stored_project.is_active is True assert enqueuer.requests == [] +@pytest.mark.asyncio +async def test_project_delete_acceptance_promotes_replacement_default( + tenant_session_maker: async_sessionmaker[AsyncSession], +) -> None: + # Deleting the default project hands the flag to another active project so + # the workspace always resolves a default for project-less writes. + default_project = await create_project( + tenant_session_maker, + is_default=True, + created_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + sibling = await create_project( + tenant_session_maker, + name="Notes", + permalink="notes", + external_id="project-notes", + created_at=datetime(2026, 2, 1, tzinfo=UTC), + ) + enqueuer = RecordingProjectDeleteEnqueuer() + service = ProjectDeleteAcceptanceService( + session_maker=tenant_session_maker, + job_enqueuer=enqueuer, + ) + + result = await service.delete_project( + ProjectDeleteAcceptanceRequest( + project_external_id="project-main", + delete_notes=False, + ) + ) + + async with tenant_session_maker() as session: + deleted = await session.get(Project, default_project.id) + promoted = await session.get(Project, sibling.id) + + assert deleted is not None + assert deleted.is_active is False + assert not deleted.is_default + assert promoted is not None + assert promoted.is_default is True + assert len(enqueuer.requests) == 1 + # The accepted response still describes the project that was removed. + assert result.old_project.external_id == "project-main" + + +@pytest.mark.asyncio +async def test_project_delete_acceptance_promotes_oldest_active_project( + tenant_session_maker: async_sessionmaker[AsyncSession], +) -> None: + # When several projects could inherit the default, the oldest active one + # wins so the promotion is deterministic. + await create_project( + tenant_session_maker, + is_default=True, + created_at=datetime(2026, 3, 1, tzinfo=UTC), + ) + older = await create_project( + tenant_session_maker, + name="Older", + permalink="older", + external_id="project-older", + created_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + newer = await create_project( + tenant_session_maker, + name="Newer", + permalink="newer", + external_id="project-newer", + created_at=datetime(2026, 2, 1, tzinfo=UTC), + ) + service = ProjectDeleteAcceptanceService( + session_maker=tenant_session_maker, + job_enqueuer=RecordingProjectDeleteEnqueuer(), + ) + + await service.delete_project( + ProjectDeleteAcceptanceRequest( + project_external_id="project-main", + delete_notes=False, + ) + ) + + async with tenant_session_maker() as session: + promoted_older = await session.get(Project, older.id) + promoted_newer = await session.get(Project, newer.id) + + assert promoted_older is not None + assert promoted_older.is_default is True + assert promoted_newer is not None + assert not promoted_newer.is_default + + @pytest.mark.asyncio async def test_project_delete_acceptance_reactivates_project_when_enqueue_fails( tenant_session_maker: async_sessionmaker[AsyncSession],