Skip to content

Commit f5c3c6c

Browse files
phernandezclaude
andauthored
refactor(core): structure campaign — collapse seams, honest types, typed boundaries (#1054)
Closes #1053. Eight clusters over three integration waves: Protocol/Default/factory towers collapsed, object-typed protocol properties narrowed with coercion deleted, seven _session_scope copies consolidated into db.scoped_session, checkpoint state pairs collapsed into single fail-fast Pydantic models (persisted JSON pinned byte-identical), hand-rolled tagged unions replaced with real unions, dict route boundaries typed (response bytes snapshot-pinned), deps/services.py restored to a composition root. 78 contract changes recorded for the paired cloud adaptation (#1489). Reviewed by a three-lens adversarial pass (17 findings fixed) plus cross-vendor Claude+Codex review (1 fixed, 1 dispositioned to cloud adaptation). Full unit suite green; ty at main baseline. Signed-off-by: phernandez <paul@basicmachines.co> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 53da71c commit f5c3c6c

87 files changed

Lines changed: 2940 additions & 3442 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/basic_memory/api/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
import logfire
3030
from basic_memory.cloud.note_content_materialization import drain_pending_materializations
3131
from basic_memory.config import init_api_logging
32-
from basic_memory.deps.services import drain_background_tasks
32+
from basic_memory.index.local_schedulers import drain_background_tasks
3333
from basic_memory.services.exceptions import EntityAlreadyExistsError
3434
from basic_memory.services.initialization import initialize_app
3535
from basic_memory.workspace_context import (

src/basic_memory/api/v2/routers/knowledge_router.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -997,18 +997,19 @@ async def delete_directory(
997997
logger.info(f"API v2 request: delete_directory directory='{data.directory}'")
998998

999999
try:
1000-
status_code, payload = await directory_delete_service.delete_directory(
1000+
result = await directory_delete_service.delete_directory(
10011001
project_external_id=project_external_id,
10021002
directory=data.directory,
10031003
)
1004+
payload = result.to_response_payload()
10041005
logger.info(
10051006
f"API v2 response: delete_directory "
1006-
f"total={payload.get('total_files')}, "
1007-
f"success={payload.get('successful_deletes')}, "
1008-
f"failed={payload.get('failed_deletes')}, "
1009-
f"file_delete_status={payload.get('file_delete_status')}"
1007+
f"total={payload['total_files']}, "
1008+
f"success={payload['successful_deletes']}, "
1009+
f"failed={payload['failed_deletes']}, "
1010+
f"file_delete_status={payload['file_delete_status']}"
10101011
)
1011-
return runtime_json_response(status_code=status_code, payload=payload)
1012+
return runtime_json_response(status_code=result.http_status_code, payload=payload)
10121013

10131014
except DirectoryDeleteServiceError as error:
10141015
logger.error(f"Error deleting directory: {error.detail}")

src/basic_memory/api/v2/routers/project_router.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
ProjectIndexCommandDep,
2525
ProjectIndexObserverDep,
2626
ProjectExternalIdPathDep,
27-
ProjectIndexRouteRequest,
2827
SessionDep,
2928
SessionMakerDep,
3029
)
30+
from basic_memory.index.local_project import ProjectIndexRouteRequest
3131
from basic_memory.schemas import ProjectIndexStatusResponse
3232
from basic_memory.models import Project
3333
from basic_memory.repository.project_repository import ProjectRepository
@@ -38,7 +38,11 @@
3838
ProjectInfoResponse,
3939
ProjectStatusResponse,
4040
)
41-
from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveResponse
41+
from basic_memory.schemas.v2 import (
42+
ProjectIndexResponse,
43+
ProjectResolveRequest,
44+
ProjectResolveResponse,
45+
)
4246
from basic_memory.utils import normalize_project_path, generate_permalink
4347

4448
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
@@ -231,14 +235,14 @@ async def synchronize_projects(
231235
raise HTTPException(status_code=400, detail=str(e))
232236

233237

234-
@router.post("/{project_id}/index")
238+
@router.post("/{project_id}/index", response_model=ProjectIndexResponse)
235239
async def index_project(
236240
project_index_command: ProjectIndexCommandDep,
237241
project_config: ProjectConfigV2ExternalDep,
238242
project_internal_id: ProjectExternalIdPathDep,
239243
force_full: bool = Query(False, description="Request a full project index run"),
240244
run_in_background: bool = Query(True, description="Run in background"),
241-
):
245+
) -> ProjectIndexResponse:
242246
"""Run project-wide indexing through the event-index coordinator."""
243247
return await project_index_command.index_project(
244248
ProjectIndexRouteRequest(

src/basic_memory/cli/commands/command_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
3333
# at CLI import time — only when a command actually runs (#886).
3434
from basic_memory import db
3535
from basic_memory.cloud.note_content_materialization import drain_pending_materializations
36-
from basic_memory.deps.services import drain_background_tasks
36+
from basic_memory.index.local_schedulers import drain_background_tasks
3737

3838
async def _with_cleanup() -> T:
3939
try:

src/basic_memory/cloud/directory_deletes.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from basic_memory import db
1515
from basic_memory.indexing.directory_delete_runner import (
1616
DirectoryDeleteAcceptanceRequest,
17+
DirectoryDeleteAcceptedResult,
1718
DirectoryDeleteRejected,
1819
DirectoryDeleteRejection,
1920
DirectoryDeleteRuntime,
@@ -65,8 +66,12 @@ async def delete_directory(
6566
*,
6667
project_external_id: str,
6768
directory: str,
68-
) -> tuple[int, dict[str, object]]:
69-
"""Delete directory entities immediately and queue file cleanup in the background."""
69+
) -> DirectoryDeleteAcceptedResult:
70+
"""Delete directory entities immediately and queue file cleanup in the background.
71+
72+
The typed result carries the route status (``http_status_code``) and the
73+
existing response contract (``to_response_payload``).
74+
"""
7075
request = DirectoryDeleteAcceptanceRequest(
7176
project_external_id=project_external_id,
7277
directory=directory,
@@ -102,8 +107,7 @@ async def delete_directory(
102107
sorted(accepted.relation_cleanup_entity_ids)
103108
)
104109

105-
status_code = 500 if result.file_delete_status == "failed" else 200
106-
return status_code, result.to_response_payload()
110+
return result
107111

108112
@staticmethod
109113
def normalize_directory_path(directory: str) -> str:

src/basic_memory/cloud/project_deletes.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@
88
from sqlalchemy import select
99
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
1010

11-
from basic_memory.indexing.project_delete_acceptance import (
12-
ProjectDeleteAcceptedProject,
13-
ProjectDeleteAcceptedResult,
14-
)
11+
from basic_memory.indexing.project_delete_acceptance import ProjectDeleteAcceptedResult
1512
from basic_memory.models import Project
1613
from basic_memory.runtime.jobs import RuntimeJobId, RuntimeProjectDeleteJobRequest
14+
from basic_memory.schemas.project_info import ProjectItem
1715

1816

1917
class ProjectDeleteAcceptanceError(Exception):
@@ -106,7 +104,13 @@ async def delete_project(
106104
project_path=project.path,
107105
delete_notes=request.delete_notes,
108106
)
109-
old_project = ProjectDeleteAcceptedProject.from_source(project)
107+
old_project = ProjectItem(
108+
id=project.id,
109+
external_id=project.external_id,
110+
name=project.name,
111+
path=project.path,
112+
is_default=project.is_default or False,
113+
)
110114
project.is_active = False
111115
await session.commit()
112116

src/basic_memory/db.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -139,31 +139,49 @@ def get_scoped_session_factory(
139139
@asynccontextmanager
140140
async def scoped_session(
141141
session_maker: async_sessionmaker[AsyncSession],
142+
session: AsyncSession | None = None,
142143
) -> AsyncGenerator[AsyncSession, None]:
143144
"""
144145
Get a scoped session with proper lifecycle management.
145146
147+
This is the one shared session-scope seam for services and indexing code.
148+
It covers both real usage variants:
149+
150+
- ``session`` provided: the caller-owned session is yielded unchanged and
151+
the caller keeps commit/rollback ownership (composed multi-step writes).
152+
- ``session`` omitted: a fresh task-scoped session is opened that commits
153+
on success, rolls back on error, and always closes.
154+
146155
Args:
147156
session_maker: Session maker to create scoped sessions from
157+
session: Optional caller-owned session to reuse instead of opening one
148158
"""
159+
# Trigger: the caller already owns a transaction and passes its session in.
160+
# Why: nested scopes must not commit or roll back mid-way through the
161+
# caller's composed write; transaction ownership stays with the opener.
162+
# Outcome: yield the session untouched and let the outermost scope finish it.
163+
if session is not None:
164+
yield session
165+
return
166+
149167
factory = get_scoped_session_factory(session_maker)
150-
session = factory()
168+
owned_session = factory()
151169
try:
152170
# Only enable foreign keys for SQLite (Postgres has them enabled by default)
153171
# Detect database type from session's bind (engine) dialect
154-
engine = session.get_bind()
172+
engine = owned_session.get_bind()
155173
dialect_name = engine.dialect.name
156174

157175
if dialect_name == "sqlite":
158-
await session.execute(text("PRAGMA foreign_keys=ON"))
176+
await owned_session.execute(text("PRAGMA foreign_keys=ON"))
159177

160-
yield session
161-
await session.commit()
178+
yield owned_session
179+
await owned_session.commit()
162180
except Exception:
163-
await session.rollback()
181+
await owned_session.rollback()
164182
raise
165183
finally:
166-
await session.close()
184+
await owned_session.close()
167185
await factory.remove()
168186

169187

src/basic_memory/deps/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@
101101
ProjectIndexSchedulerDep,
102102
get_project_index_command,
103103
ProjectIndexCommandDep,
104-
ProjectIndexRouteRequest,
105104
get_search_reindex_scheduler,
106105
SearchReindexSchedulerDep,
107106
get_search_service,
@@ -258,7 +257,6 @@
258257
"ProjectIndexSchedulerDep",
259258
"get_project_index_command",
260259
"ProjectIndexCommandDep",
261-
"ProjectIndexRouteRequest",
262260
"get_search_reindex_scheduler",
263261
"SearchReindexSchedulerDep",
264262
"get_search_service",

0 commit comments

Comments
 (0)