Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
0cc92d7
refactor(core): type note-content runtime protocols with honest ORM f…
phernandez Jul 14, 2026
76deef2
refactor(core): drop redundant coercions in note materialization pref…
phernandez Jul 14, 2026
c0a16f1
refactor(core): take honest entity field types in file-index result b…
phernandez Jul 14, 2026
cd5f946
refactor(sync): type local watcher project protocols honestly
phernandez Jul 14, 2026
c6ef207
refactor(core): drop redundant casts from accepted note mutation runner
phernandez Jul 14, 2026
f412db5
refactor(core): collapse per-service _session_scope copies into db.sc…
phernandez Jul 14, 2026
1a23f5e
refactor(core): collapse indexing checkpoint runtime/state pairs
phernandez Jul 14, 2026
15c904d
refactor(core): replace hand-rolled workflow plan tagged unions with …
phernandez Jul 14, 2026
0c4e844
refactor(core): write workflow checkpoint metadata through typed models
phernandez Jul 14, 2026
05d59fc
refactor(core): collapse accepted-note repository default towers
phernandez Jul 14, 2026
910f8fa
refactor(core): collapse project-delete repository factory hop
phernandez Jul 14, 2026
31fbaaa
refactor(core): flatten index-file metadata-source tower
phernandez Jul 14, 2026
97e24c2
refactor(core): drop DefaultIndexBatchRuntime pass-through wrapper
phernandez Jul 14, 2026
4b05ca2
refactor(core): collapse indexed note-content timestamp seam to one c…
phernandez Jul 14, 2026
d80543d
refactor(core): collapse note-content store factory chain to one seam
phernandez Jul 14, 2026
02062a5
refactor(core): name the metadata-payload exclude set as a typed builder
phernandez Jul 14, 2026
3db8303
refactor(core): flatten read-repair repository lattice to default paths
phernandez Jul 14, 2026
dd0cdc9
test(core): cover the stale-db-version publish path in the repository…
phernandez Jul 14, 2026
079b9b2
refactor(core): serialize accepted project-delete responses through P…
phernandez Jul 14, 2026
279fe69
refactor(core): type the directory-delete route boundary end to end
phernandez Jul 14, 2026
51641fc
refactor(core): extract the move-target eligibility rule into one pla…
phernandez Jul 14, 2026
dd415aa
refactor(core): restructure apply_project_index_move_batch into liter…
phernandez Jul 14, 2026
07ff09f
refactor(core): use RuntimeJobCounts directly for storage-event results
phernandez Jul 14, 2026
f1d8f9b
refactor(core): collapse relation-resolution protocol split and loop hop
phernandez Jul 14, 2026
0d0e1a7
refactor(core): type the project-index relation-resolution context ho…
phernandez Jul 14, 2026
e684299
refactor(core): name the relation-resolution repository contract hone…
phernandez Jul 14, 2026
9a33de9
refactor(core): unify forward-reference protocols with relation_resol…
phernandez Jul 14, 2026
69b17d9
refactor(core): delegate ProjectIndexRuntime maintenance to the store…
phernandez Jul 14, 2026
6a65086
refactor(core): drop unused runtime payload-enqueue abstraction
phernandez Jul 14, 2026
559b0d8
refactor(core): move local runtime implementations out of deps/services
phernandez Jul 14, 2026
88b0f21
refactor(api): type the project-index route boundary end to end
phernandez Jul 14, 2026
f9d7c76
refactor(core): revalidate vector sync progress at checkpoint write time
phernandez Jul 14, 2026
435e477
refactor(core): restore identity guard in completion relation planning
phernandez Jul 14, 2026
d79e88f
refactor(core): restore identity validation in file-index result models
phernandez Jul 14, 2026
74f40ac
refactor(core): restore deleted-note identity validation for live upd…
phernandez Jul 14, 2026
f6f2bde
refactor(core): restore version coercion in note-content guards
phernandez Jul 14, 2026
ca1a147
refactor(core): restore duck-typing fallback in local watcher prefixes
phernandez Jul 14, 2026
f79db1f
refactor(core): import drain_background_tasks from its home module
phernandez Jul 14, 2026
5fc6e63
refactor(core): restore coercion in pending materialization planning
phernandez Jul 14, 2026
61b0f48
test: cover empty-path guard in local watcher root resolution
phernandez Jul 14, 2026
5714abc
fix(core): fail fast on malformed runtime checkpoint values
phernandez Jul 14, 2026
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
2 changes: 1 addition & 1 deletion src/basic_memory/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
import logfire
from basic_memory.cloud.note_content_materialization import drain_pending_materializations
from basic_memory.config import init_api_logging
from basic_memory.deps.services import drain_background_tasks
from basic_memory.index.local_schedulers import drain_background_tasks
from basic_memory.services.exceptions import EntityAlreadyExistsError
from basic_memory.services.initialization import initialize_app
from basic_memory.workspace_context import (
Expand Down
13 changes: 7 additions & 6 deletions src/basic_memory/api/v2/routers/knowledge_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -997,18 +997,19 @@ async def delete_directory(
logger.info(f"API v2 request: delete_directory directory='{data.directory}'")

try:
status_code, payload = await directory_delete_service.delete_directory(
result = await directory_delete_service.delete_directory(
project_external_id=project_external_id,
directory=data.directory,
)
payload = result.to_response_payload()
logger.info(
f"API v2 response: delete_directory "
f"total={payload.get('total_files')}, "
f"success={payload.get('successful_deletes')}, "
f"failed={payload.get('failed_deletes')}, "
f"file_delete_status={payload.get('file_delete_status')}"
f"total={payload['total_files']}, "
f"success={payload['successful_deletes']}, "
f"failed={payload['failed_deletes']}, "
f"file_delete_status={payload['file_delete_status']}"
)
return runtime_json_response(status_code=status_code, payload=payload)
return runtime_json_response(status_code=result.http_status_code, payload=payload)

except DirectoryDeleteServiceError as error:
logger.error(f"Error deleting directory: {error.detail}")
Expand Down
12 changes: 8 additions & 4 deletions src/basic_memory/api/v2/routers/project_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@
ProjectIndexCommandDep,
ProjectIndexObserverDep,
ProjectExternalIdPathDep,
ProjectIndexRouteRequest,
SessionDep,
SessionMakerDep,
)
from basic_memory.index.local_project import ProjectIndexRouteRequest
from basic_memory.schemas import ProjectIndexStatusResponse
from basic_memory.models import Project
from basic_memory.repository.project_repository import ProjectRepository
Expand All @@ -38,7 +38,11 @@
ProjectInfoResponse,
ProjectStatusResponse,
)
from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveResponse
from basic_memory.schemas.v2 import (
ProjectIndexResponse,
ProjectResolveRequest,
ProjectResolveResponse,
)
from basic_memory.utils import normalize_project_path, generate_permalink

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


@router.post("/{project_id}/index")
@router.post("/{project_id}/index", response_model=ProjectIndexResponse)
async def index_project(
project_index_command: ProjectIndexCommandDep,
project_config: ProjectConfigV2ExternalDep,
project_internal_id: ProjectExternalIdPathDep,
force_full: bool = Query(False, description="Request a full project index run"),
run_in_background: bool = Query(True, description="Run in background"),
):
) -> ProjectIndexResponse:
"""Run project-wide indexing through the event-index coordinator."""
return await project_index_command.index_project(
ProjectIndexRouteRequest(
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/cli/commands/command_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
# at CLI import time — only when a command actually runs (#886).
from basic_memory import db
from basic_memory.cloud.note_content_materialization import drain_pending_materializations
from basic_memory.deps.services import drain_background_tasks
from basic_memory.index.local_schedulers import drain_background_tasks

async def _with_cleanup() -> T:
try:
Expand Down
12 changes: 8 additions & 4 deletions src/basic_memory/cloud/directory_deletes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from basic_memory import db
from basic_memory.indexing.directory_delete_runner import (
DirectoryDeleteAcceptanceRequest,
DirectoryDeleteAcceptedResult,
DirectoryDeleteRejected,
DirectoryDeleteRejection,
DirectoryDeleteRuntime,
Expand Down Expand Up @@ -65,8 +66,12 @@ async def delete_directory(
*,
project_external_id: str,
directory: str,
) -> tuple[int, dict[str, object]]:
"""Delete directory entities immediately and queue file cleanup in the background."""
) -> DirectoryDeleteAcceptedResult:
"""Delete directory entities immediately and queue file cleanup in the background.

The typed result carries the route status (``http_status_code``) and the
existing response contract (``to_response_payload``).
"""
request = DirectoryDeleteAcceptanceRequest(
project_external_id=project_external_id,
directory=directory,
Expand Down Expand Up @@ -102,8 +107,7 @@ async def delete_directory(
sorted(accepted.relation_cleanup_entity_ids)
)

status_code = 500 if result.file_delete_status == "failed" else 200
return status_code, result.to_response_payload()
return result

@staticmethod
def normalize_directory_path(directory: str) -> str:
Expand Down
14 changes: 9 additions & 5 deletions src/basic_memory/cloud/project_deletes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from basic_memory.indexing.project_delete_acceptance import (
ProjectDeleteAcceptedProject,
ProjectDeleteAcceptedResult,
)
from basic_memory.indexing.project_delete_acceptance import ProjectDeleteAcceptedResult
from basic_memory.models import Project
from basic_memory.runtime.jobs import RuntimeJobId, RuntimeProjectDeleteJobRequest
from basic_memory.schemas.project_info import ProjectItem


class ProjectDeleteAcceptanceError(Exception):
Expand Down Expand Up @@ -106,7 +104,13 @@ async def delete_project(
project_path=project.path,
delete_notes=request.delete_notes,
)
old_project = ProjectDeleteAcceptedProject.from_source(project)
old_project = ProjectItem(
id=project.id,
external_id=project.external_id,
name=project.name,
path=project.path,
is_default=project.is_default or False,
)
project.is_active = False
await session.commit()

Expand Down
32 changes: 25 additions & 7 deletions src/basic_memory/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,31 +139,49 @@ def get_scoped_session_factory(
@asynccontextmanager
async def scoped_session(
session_maker: async_sessionmaker[AsyncSession],
session: AsyncSession | None = None,
) -> AsyncGenerator[AsyncSession, None]:
"""
Get a scoped session with proper lifecycle management.

This is the one shared session-scope seam for services and indexing code.
It covers both real usage variants:

- ``session`` provided: the caller-owned session is yielded unchanged and
the caller keeps commit/rollback ownership (composed multi-step writes).
- ``session`` omitted: a fresh task-scoped session is opened that commits
on success, rolls back on error, and always closes.

Args:
session_maker: Session maker to create scoped sessions from
session: Optional caller-owned session to reuse instead of opening one
"""
# Trigger: the caller already owns a transaction and passes its session in.
# Why: nested scopes must not commit or roll back mid-way through the
# caller's composed write; transaction ownership stays with the opener.
# Outcome: yield the session untouched and let the outermost scope finish it.
if session is not None:
yield session
return

factory = get_scoped_session_factory(session_maker)
session = factory()
owned_session = factory()
try:
# Only enable foreign keys for SQLite (Postgres has them enabled by default)
# Detect database type from session's bind (engine) dialect
engine = session.get_bind()
engine = owned_session.get_bind()
dialect_name = engine.dialect.name

if dialect_name == "sqlite":
await session.execute(text("PRAGMA foreign_keys=ON"))
await owned_session.execute(text("PRAGMA foreign_keys=ON"))

yield session
await session.commit()
yield owned_session
await owned_session.commit()
except Exception:
await session.rollback()
await owned_session.rollback()
raise
finally:
await session.close()
await owned_session.close()
await factory.remove()


Expand Down
2 changes: 0 additions & 2 deletions src/basic_memory/deps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@
ProjectIndexSchedulerDep,
get_project_index_command,
ProjectIndexCommandDep,
ProjectIndexRouteRequest,
get_search_reindex_scheduler,
SearchReindexSchedulerDep,
get_search_service,
Expand Down Expand Up @@ -258,7 +257,6 @@
"ProjectIndexSchedulerDep",
"get_project_index_command",
"ProjectIndexCommandDep",
"ProjectIndexRouteRequest",
"get_search_reindex_scheduler",
"SearchReindexSchedulerDep",
"get_search_service",
Expand Down
Loading
Loading