The previous revision gated project_id forwarding on the
`cloud_available` composite (factory_mode OR explicit_cloud OR
has_cloud_credentials), mirroring get_project_client. But
`has_cloud_credentials` returns True any time OAuth tokens linger
from a past `bm cloud login`, even when the user is back to working
purely locally. So on a typical dev box the fan-out still forwarded
project_id, hit get_project_client's UUID branch (which treats unknown
identifiers as cloud since local config doesn't key by UUID), and 401d
silently — leaving merged results empty.
Route by name only: project_refs already carry the
workspace/project qualified_name, which is just as unambiguous as the
external_id for both backends. project_id stays in the call signature
purely as a fallback for refs that unexpectedly have no name.
Confirmed live in a restarted MCP — the previous fix did not actually
deliver multi-project results when the dev environment had any cloud
credentials at all.
Tests now drop the routing-mode stubs; the cloud-style tests just key
their MockSearchClient on the workspace-qualified name passed via the
project parameter.
Signed-off-by: phernandez <paul@basicmachines.co>
Three follow-ups from PR review:
**Codex P2 — Load sqlite-vec on the delete session.** The previous
revision swallowed every `vec0` OperationalError as "no embeddings
exist", but sqlite-vec is loaded **per connection**: a pooled
connection that hosts ProjectRepository.delete may not have vec0
loaded even when another connection successfully wrote embeddings.
That would silently leave orphan vectors behind.
The new `_load_sqlite_vec_on_session` helper mirrors
SQLiteSearchRepository._ensure_sqlite_vec_loaded as a free function
and tries to load the extension on the current session. Only when the
load itself fails — because the Python build lacks
enable_load_extension, or the sqlite_vec package isn't installed — do
we skip the embeddings DELETE. Every connection in the pool shares the
same interpreter, so in that case no embeddings could have been
written from any connection and skipping is safe.
**Claude review — Missing logger.debug calls.** The override now logs
at entry, when the project id isn't found, and after the ORM delete,
matching the base Repository.delete contract.
**Claude review — NoResultFound branch uncovered.** New test
`test_delete_returns_false_for_missing_project_id` asserts the False
return for a nonexistent project id.
Verified locally: 32 passed / 1 skipped (SQLite), 27 passed / 1
skipped (Postgres via testcontainers).
Signed-off-by: phernandez <paul@basicmachines.co>
Two follow-up fixes on the same branch:
1. **Purge sqlite-vec embeddings during project delete** (Codex P2)
sqlite-vec stores vectors in a vec0 virtual table keyed by chunk rowid
with no cascade. The previous purge removed search_vector_chunks but
left the embeddings behind; `_run_vector_query` then keeps returning
stale vectors that crowd live results.
ProjectRepository.delete now deletes embeddings first (using the same
rowid-IN-chunks pattern as SQLiteSearchRepository.delete_project_vector_rows),
then the chunk rows. Both deletes are skipped if the underlying table
is absent on a given install. New test test_remove_project_purges_vector_embeddings
covers the happy path and skips cleanly when the embeddings table
isn't initialized.
2. **Fix search_all_projects=True on local installs**
`_search_all_projects` recurses into search_notes with both project=
and project_id= set. project_id (external UUID) routes through the
cloud v2 API path, which 401s on local installs because there's no
JWT to present — so the inner calls silently failed and the merged
result list stayed empty.
The fan-out now mirrors get_project_client's cloud_available composite
(factory mode OR explicit --cloud OR has_cloud_credentials). When that
composite is false we forward project= only and take the name-routed
local-ASGI path. Cloud disambiguation still works because the project
name in project_ref is already the workspace/project qualified_name.
The existing cloud-style fan-out tests now go through a cloud_routing
fixture that pins the three signals; a new local_routing test confirms
project_id is dropped when no cloud route is available.
Signed-off-by: phernandez <paul@basicmachines.co>
SQLite stores search_index as an FTS5 virtual table, which can't carry a
foreign key, so the ON DELETE CASCADE from search_index.project_id to
project.id only applies on Postgres. On SQLite, deleting a project left
its FTS rows behind — and when auto-increment handed the same id to a
new project, the leftover rows masqueraded as the new tenant's data and
leaked into searches scoped to that project.
- ProjectRepository.delete now explicitly purges search_index and
search_vector_chunks for the project id in the same session before
the ORM delete. Idempotent on Postgres (the cascade FK still runs).
- One-time cleanup migration sweeps two leftover shapes: rows whose
project_id is gone, and rows whose entity_id is gone (the larger
class from id reuse). Guarded by table-existence checks so fresh
SQLite installs — where search_index is created at runtime by
init_search_index, not by Alembic — don't fail the upgrade.
- Regression test seeds both derived tables, calls remove_project,
and asserts both come out clean. Verified red on pre-fix code.
Signed-off-by: phernandez <paul@basicmachines.co>