|
4 | 4 | from typing import Optional, Sequence, Union |
5 | 5 |
|
6 | 6 |
|
7 | | -from sqlalchemy import text |
| 7 | +from loguru import logger |
| 8 | +from sqlalchemy import inspect as sa_inspect, select, text |
| 9 | +from sqlalchemy.exc import NoResultFound, OperationalError |
8 | 10 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker |
9 | 11 |
|
10 | 12 | from basic_memory import db |
11 | 13 | from basic_memory.models.project import Project |
12 | 14 | from basic_memory.repository.repository import Repository |
13 | 15 |
|
14 | 16 |
|
| 17 | +async def _load_sqlite_vec_on_session(session) -> bool: |
| 18 | + """Ensure the sqlite-vec extension is loaded on this session's connection. |
| 19 | +
|
| 20 | + Returns True when vec0 is available after the call. Returns False when the |
| 21 | + extension can't be loaded on this Python build (e.g., python.org macOS or |
| 22 | + Windows interpreters without `enable_load_extension`) — every connection in |
| 23 | + the pool shares the same interpreter, so a False here also means no |
| 24 | + embedding row could ever have been written, and skipping the embeddings |
| 25 | + purge is safe. |
| 26 | +
|
| 27 | + Mirrors SQLiteSearchRepository._ensure_sqlite_vec_loaded but as a free |
| 28 | + function: we don't have a SearchRepository instance during project delete, |
| 29 | + and the per-connection nature of extension loading means a pooled connection |
| 30 | + routed to this session might not have vec loaded even when other |
| 31 | + connections wrote embeddings. |
| 32 | + """ |
| 33 | + try: |
| 34 | + await session.execute(text("SELECT vec_version()")) |
| 35 | + return True |
| 36 | + except OperationalError: |
| 37 | + pass |
| 38 | + |
| 39 | + try: |
| 40 | + import sqlite_vec # type: ignore[import-not-found] |
| 41 | + except ImportError: |
| 42 | + logger.debug("sqlite-vec package not installed; skipping vec purge") |
| 43 | + return False |
| 44 | + |
| 45 | + async_connection = await session.connection() |
| 46 | + raw_connection = await async_connection.get_raw_connection() |
| 47 | + driver_connection = raw_connection.driver_connection |
| 48 | + |
| 49 | + if not hasattr(driver_connection, "enable_load_extension"): |
| 50 | + # Trigger: CPython build without sqlite extension support (#711). |
| 51 | + # Why: load_extension is unavailable, so no connection in this pool |
| 52 | + # can host vec0. No embeddings exist anywhere. |
| 53 | + # Outcome: skip the embeddings purge entirely. |
| 54 | + logger.debug( |
| 55 | + "Skipping search_vector_embeddings purge: this Python build does " |
| 56 | + "not support SQLite extension loading" |
| 57 | + ) |
| 58 | + return False |
| 59 | + |
| 60 | + try: |
| 61 | + await driver_connection.enable_load_extension(True) |
| 62 | + await driver_connection.load_extension(sqlite_vec.loadable_path()) |
| 63 | + await driver_connection.enable_load_extension(False) |
| 64 | + await session.execute(text("SELECT vec_version()")) |
| 65 | + except Exception as exc: |
| 66 | + logger.warning( |
| 67 | + "Failed to load sqlite-vec for project delete cleanup; " |
| 68 | + "skipping embeddings purge: {}", |
| 69 | + exc, |
| 70 | + ) |
| 71 | + return False |
| 72 | + |
| 73 | + return True |
| 74 | + |
| 75 | + |
15 | 76 | class ProjectRepository(Repository[Project]): |
16 | 77 | """Repository for Project model. |
17 | 78 |
|
@@ -121,6 +182,83 @@ async def set_as_default(self, project_id: int) -> Optional[Project]: |
121 | 182 | return target_project |
122 | 183 | return None # pragma: no cover |
123 | 184 |
|
| 185 | + async def delete(self, entity_id: int) -> bool: |
| 186 | + """Delete a project and its derived search rows in one transaction. |
| 187 | +
|
| 188 | + The cascade picture differs by backend: |
| 189 | +
|
| 190 | + - search_index → project: Postgres has ON DELETE CASCADE FK; SQLite |
| 191 | + stores search_index as an FTS5 virtual table and can't carry FKs, |
| 192 | + so it needs explicit cleanup. |
| 193 | + - search_vector_chunks → project: neither backend has an FK here, so |
| 194 | + both need an explicit DELETE. |
| 195 | + - search_vector_embeddings → search_vector_chunks: Postgres has an FK |
| 196 | + (chunk_id REFERENCES … ON DELETE CASCADE); SQLite stores embeddings |
| 197 | + in a vec0 virtual table keyed by rowid with no cascade. On SQLite |
| 198 | + the embeddings must be purged before the chunk rows, otherwise |
| 199 | + `_run_vector_query` keeps returning stale vectors that crowd out |
| 200 | + live results. |
| 201 | +
|
| 202 | + Each derived table is created lazily (search_index by |
| 203 | + SearchRepository.init_search_index, the vector tables once semantic |
| 204 | + search initializes), so any of them may be absent on minimal test DBs. |
| 205 | + Inspect the connection once and skip whichever is missing. |
| 206 | + """ |
| 207 | + logger.debug(f"Deleting Project and search rows for project_id: {entity_id}") |
| 208 | + async with db.scoped_session(self.session_maker) as session: |
| 209 | + try: |
| 210 | + result = await session.execute( |
| 211 | + select(self.Model).filter(self.primary_key == entity_id) |
| 212 | + ) |
| 213 | + project = result.scalars().one() |
| 214 | + except NoResultFound: |
| 215 | + logger.debug(f"No Project found to delete: {entity_id}") |
| 216 | + return False |
| 217 | + |
| 218 | + dialect_name = session.bind.dialect.name if session.bind else "sqlite" |
| 219 | + is_sqlite = dialect_name == "sqlite" |
| 220 | + |
| 221 | + existing_tables = await session.run_sync( |
| 222 | + lambda sync_session: set(sa_inspect(sync_session.connection()).get_table_names()) |
| 223 | + ) |
| 224 | + |
| 225 | + # search_index: SQLite has no FK on the FTS5 virtual table; Postgres |
| 226 | + # cascades from the project FK, so the explicit DELETE is redundant. |
| 227 | + if is_sqlite and "search_index" in existing_tables: |
| 228 | + await session.execute( |
| 229 | + text("DELETE FROM search_index WHERE project_id = :project_id"), |
| 230 | + {"project_id": entity_id}, |
| 231 | + ) |
| 232 | + |
| 233 | + # search_vector_chunks: no FK to project on either backend, so both |
| 234 | + # backends need this. SQLite must purge vec0 embeddings first |
| 235 | + # (rowid pseudocolumn — Postgres uses chunk_id and would 500 here); |
| 236 | + # Postgres' chunk_id FK CASCADE handles its embeddings cleanup when |
| 237 | + # we delete the chunk rows below. |
| 238 | + if "search_vector_chunks" in existing_tables: |
| 239 | + if is_sqlite and "search_vector_embeddings" in existing_tables: |
| 240 | + # Extension loading is per-connection. We must load vec0 on |
| 241 | + # *this* session before the DELETE; otherwise a different |
| 242 | + # pooled connection might have written embeddings that we'd |
| 243 | + # silently leave behind. |
| 244 | + if await _load_sqlite_vec_on_session(session): |
| 245 | + await session.execute( |
| 246 | + text( |
| 247 | + "DELETE FROM search_vector_embeddings WHERE rowid IN (" |
| 248 | + "SELECT id FROM search_vector_chunks " |
| 249 | + "WHERE project_id = :project_id)" |
| 250 | + ), |
| 251 | + {"project_id": entity_id}, |
| 252 | + ) |
| 253 | + await session.execute( |
| 254 | + text("DELETE FROM search_vector_chunks WHERE project_id = :project_id"), |
| 255 | + {"project_id": entity_id}, |
| 256 | + ) |
| 257 | + |
| 258 | + await session.delete(project) |
| 259 | + logger.debug(f"Deleted Project and search rows for project_id: {entity_id}") |
| 260 | + return True |
| 261 | + |
124 | 262 | async def update_path(self, project_id: int, new_path: str) -> Optional[Project]: |
125 | 263 | """Update project path. |
126 | 264 |
|
|
0 commit comments