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
32 changes: 32 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,38 @@ from basic_memory.deps import *

New code should import from specific submodules (`basic_memory.deps.services`) for clarity.

## Runtime Package Boundaries

Accepted-note orchestration is shared by local and hosted runtimes. Its dependencies flow in one
direction:

```
schemas and runtime values
repositories
portable services and indexing workflows
local or hosted composition roots and adapters
```

- `repository/` owns explicit-session persistence operations and persisted row values, including
accepted-note search rows and vector cleanup. It must not import `indexing/` workflows.
- `services/` owns runtime-neutral note preparation, note-content reads and writes, and delete
operations. These modules receive storage and repository capabilities explicitly.
- `indexing/` owns portable mutation, reconciliation, materialization, and project-index workflows.
- `index/` contains the local runtime's concrete adapters and composition helpers. Shared contracts
such as project-index requests and scheduler capabilities live in neutral modules within this
package; hosted code must not depend on `Local*` implementations.
- `cloud/` contains compatibility exports for downstream releases. Core production modules must
not import from it; new downstream code imports the neutral owner directly.

Accepted Markdown create, replace, and edit operations cross one public persistence boundary:
`persist_accepted_note_snapshot`. That operation writes the entity snapshot, `NoteContent`, graph
rows, and entity search row inside the caller's transaction. Move intentionally uses the narrower
`persist_accepted_note_move` operation because changing a path must not replace observations or
relations.

## MCP Tools Architecture

### Typed API Clients
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
synchronize_projects,
)
import logfire
from basic_memory.cloud.note_content_materialization import drain_pending_materializations
from basic_memory.index.note_content_materialization import drain_pending_materializations
from basic_memory.config import init_api_logging
from basic_memory.index.local_schedulers import drain_background_tasks
from basic_memory.services.exceptions import EntityAlreadyExistsError
Expand Down
3 changes: 2 additions & 1 deletion src/basic_memory/api/v2/routers/knowledge_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@

import logfire
from basic_memory import db
from basic_memory.cloud import DirectoryDeleteServiceError, NoteContentMutationServiceError
from basic_memory.services.directory_deletes import DirectoryDeleteServiceError
from basic_memory.services.note_content_writes import NoteContentMutationServiceError
from basic_memory.ignore_utils import (
IGNORED_PATH_REJECTION_DETAIL,
load_gitignore_patterns,
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 @@ -32,7 +32,7 @@ def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
# Deferred: basic_memory.db pulls SQLAlchemy + Alembic, which must not load
# 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.index.note_content_materialization import drain_pending_materializations
from basic_memory.index.local_schedulers import drain_background_tasks

async def _with_cleanup() -> T:
Expand Down
119 changes: 2 additions & 117 deletions src/basic_memory/cloud/directory_deletes.py
Original file line number Diff line number Diff line change
@@ -1,118 +1,3 @@
"""Shared directory-delete service facade.
"""Compatibility shim; import from ``basic_memory.services.directory_deletes``."""

Runtime-specific callers provide the session boundary and the file cleanup
enqueuer. The core service owns request acceptance and response shaping.
"""

from __future__ import annotations

from contextlib import AbstractAsyncContextManager
from typing import Protocol

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from basic_memory import db
from basic_memory.indexing.directory_delete_runner import (
DirectoryDeleteAcceptanceRequest,
DirectoryDeleteAcceptedResult,
DirectoryDeleteRejected,
DirectoryDeleteRejection,
DirectoryDeleteRuntime,
accept_directory_delete,
finish_directory_delete_acceptance,
normalize_directory_delete_path,
)


class DirectoryDeleteSessionMaker(Protocol):
"""Session factory capability needed by directory-delete acceptance."""

def __call__(self) -> AbstractAsyncContextManager[AsyncSession]: ...


class DirectoryDeleteServiceError(Exception):
"""Structured directory-delete service error for route adapters."""

def __init__(self, status_code: int, detail: str) -> None:
super().__init__(detail)
self.status_code = status_code
self.detail = detail


def directory_delete_service_error_from_rejection(
rejection: DirectoryDeleteRejection,
) -> DirectoryDeleteServiceError:
"""Map core directory-delete rejections into route-facing errors."""
return DirectoryDeleteServiceError(
rejection.kind.http_status_code,
rejection.detail,
)


class DirectoryDeleteService:
"""Accept directory deletes into project DB state before storage cleanup begins."""

def __init__(
self,
*,
session_maker: async_sessionmaker[AsyncSession],
runtime: DirectoryDeleteRuntime,
) -> None:
self.session_maker = session_maker
self.runtime = runtime

async def delete_directory(
self,
*,
project_external_id: str,
directory: str,
) -> 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,
)
try:
# scoped_session enables `PRAGMA foreign_keys=ON` for SQLite; this bulk
# delete issues a Core DELETE on entity and relies on ON DELETE CASCADE
# for note_content/observations/relations, which a raw session_maker()
# connection (foreign_keys OFF by default) would leave orphaned.
async with db.scoped_session(self.session_maker) as session:
accepted = await accept_directory_delete(
session,
request=request,
store=self.runtime.store,
)
except DirectoryDeleteRejected as error:
raise directory_delete_service_error_from_rejection(error.rejection) from error

result = await finish_directory_delete_acceptance(
request=request,
accepted=accepted,
enqueuer=self.runtime.file_delete_enqueuer,
)

# Trigger: notes outside the deleted directory linked into it.
# Why: the delete cascaded their relation rows away, but those sources own
# matching search_index relation rows that now dangle; without a reindex
# they linger until an unrelated rebuild.
# Outcome: reindex each surviving source inline when the runtime provides a
# refresher (local); queued runtimes consume the ids from the result.
if accepted.relation_cleanup_entity_ids and self.runtime.relation_cleanup_refresher:
await self.runtime.relation_cleanup_refresher.refresh_relation_sources(
sorted(accepted.relation_cleanup_entity_ids)
)

return result

@staticmethod
def normalize_directory_path(directory: str) -> str:
"""Normalize a project-relative directory path or reject traversal."""
try:
return normalize_directory_delete_path(directory)
except ValueError as exc:
raise DirectoryDeleteServiceError(400, "Invalid directory path") from exc
from basic_memory.services.directory_deletes import * # noqa: F403
Loading
Loading