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
45 changes: 41 additions & 4 deletions src/basic_memory/services/project_deletes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +69 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Lock the promoted project before handing it the default

When one request deletes the current default while another simultaneously deletes the sibling selected here, this query reads the replacement without locking it. The sibling delete can commit after this selection but before this transaction sets replacement_default.is_default = True, leaving an inactive row marked as the default and no active default project; selecting the replacement with FOR UPDATE or rechecking its active state before commit would keep the promotion and delete paths serialized.

Useful? React with 👍 / 👎.

)
return result.scalars().first()


async def reactivate_accepted_project(
session_maker: async_sessionmaker[AsyncSession],
*,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Comment on lines +149 to +151

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the default flags when enqueue fails

If deleting a default project reaches this promotion and enqueue_project_delete() then raises, the except path only calls reactivate_accepted_project(), which restores is_active on the deleted project but never restores its is_default flag or demotes the replacement. The delete request fails, yet the workspace default has silently moved to another project, so later project-less writes can be routed to the wrong project after a transient queue outage.

Useful? React with 👍 / 👎.

await session.commit()

try:
Expand Down
116 changes: 110 additions & 6 deletions tests/cloud/test_project_deletes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from collections.abc import AsyncGenerator
from datetime import UTC, datetime

import pytest
import pytest_asyncio
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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],
Expand Down
Loading