Skip to content

Commit a98e7e4

Browse files
claude[bot]jope-bm
andcommitted
fix: Add missing project_id filter in search index_item method
Fixes critical bug where editing notes causes them to disappear from the index. The issue was in SearchRepository.index_item() method (line 526) which was missing the project_id filter when deleting existing records before re-indexing. This caused edit operations to delete search index records with the same permalink from ALL projects, not just the current project. Fixed by adding the missing 'AND project_id = :project_id' filter to match the pattern used in other delete methods (delete_by_entity_id, delete_by_permalink). Added comprehensive regression tests to ensure project isolation is maintained during edit operations and that existing records are properly updated within the same project. Resolves: #256 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: jope-bm <jope-bm@users.noreply.github.com> Signed-off-by: Joe P <joe@basicmemory.com>
1 parent 63ae9ee commit a98e7e4

2 files changed

Lines changed: 273 additions & 2 deletions

File tree

src/basic_memory/repository/search_repository.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,8 +523,8 @@ async def index_item(
523523
async with db.scoped_session(self.session_maker) as session:
524524
# Delete existing record if any
525525
await session.execute(
526-
text("DELETE FROM search_index WHERE permalink = :permalink"),
527-
{"permalink": search_index_row.permalink},
526+
text("DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"),
527+
{"permalink": search_index_row.permalink, "project_id": self.project_id},
528528
)
529529

530530
# Prepare data for insert with project_id
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
"""Tests for the search repository edit bug fix.
2+
3+
This test reproduces the critical bug where editing notes causes them to disappear
4+
from the search index due to missing project_id filter in index_item() method.
5+
"""
6+
7+
from datetime import datetime, timezone
8+
9+
import pytest
10+
import pytest_asyncio
11+
12+
from basic_memory.models import Entity
13+
from basic_memory.models.project import Project
14+
from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
15+
from basic_memory.schemas.search import SearchItemType
16+
17+
18+
@pytest_asyncio.fixture
19+
async def second_test_project(project_repository):
20+
"""Create a second project for testing project isolation during edits."""
21+
project_data = {
22+
"name": "Second Edit Test Project",
23+
"description": "Another project for testing edit bug",
24+
"path": "/second/edit/test/path",
25+
"is_active": True,
26+
"is_default": None,
27+
}
28+
return await project_repository.create(project_data)
29+
30+
31+
@pytest_asyncio.fixture
32+
async def second_search_repo(session_maker, second_test_project):
33+
"""Create a search repository for the second project."""
34+
return SearchRepository(session_maker, project_id=second_test_project.id)
35+
36+
37+
@pytest.mark.asyncio
38+
async def test_index_item_respects_project_isolation_during_edit():
39+
"""Test that index_item() doesn't delete records from other projects during edits.
40+
41+
This test reproduces the critical bug where editing a note in one project
42+
would delete search index entries with the same permalink from ALL projects,
43+
causing notes to disappear from the search index.
44+
"""
45+
from basic_memory import db
46+
from basic_memory.models.project import Project
47+
from basic_memory.repository.search_repository import SearchRepository
48+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
49+
50+
# Create a separate in-memory database for this test
51+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
52+
session_maker = async_sessionmaker(engine, expire_on_commit=False)
53+
54+
# Create the database schema
55+
async with engine.begin() as conn:
56+
await conn.run_sync(db.Base.metadata.create_all)
57+
58+
# Create two projects
59+
async with db.scoped_session(session_maker) as session:
60+
project1 = Project(
61+
name="Project 1",
62+
description="First project",
63+
path="/project1/path",
64+
is_active=True,
65+
is_default=True
66+
)
67+
project2 = Project(
68+
name="Project 2",
69+
description="Second project",
70+
path="/project2/path",
71+
is_active=True,
72+
is_default=False
73+
)
74+
session.add(project1)
75+
session.add(project2)
76+
await session.flush()
77+
78+
project1_id = project1.id
79+
project2_id = project2.id
80+
await session.commit()
81+
82+
# Create search repositories for both projects
83+
repo1 = SearchRepository(session_maker, project_id=project1_id)
84+
repo2 = SearchRepository(session_maker, project_id=project2_id)
85+
86+
# Initialize search index
87+
await repo1.init_search_index()
88+
89+
# Create two notes with the SAME permalink in different projects
90+
# This simulates the same note name/structure across different projects
91+
same_permalink = "notes/test-note"
92+
93+
search_row1 = SearchIndexRow(
94+
id=1,
95+
type=SearchItemType.ENTITY.value,
96+
title="Test Note in Project 1",
97+
content_stems="project 1 content original",
98+
content_snippet="This is the original content in project 1",
99+
permalink=same_permalink,
100+
file_path="notes/test_note.md",
101+
entity_id=1,
102+
metadata={"entity_type": "note"},
103+
created_at=datetime.now(timezone.utc),
104+
updated_at=datetime.now(timezone.utc),
105+
project_id=project1_id,
106+
)
107+
108+
search_row2 = SearchIndexRow(
109+
id=2,
110+
type=SearchItemType.ENTITY.value,
111+
title="Test Note in Project 2",
112+
content_stems="project 2 content original",
113+
content_snippet="This is the original content in project 2",
114+
permalink=same_permalink, # SAME permalink as project 1
115+
file_path="notes/test_note.md",
116+
entity_id=2,
117+
metadata={"entity_type": "note"},
118+
created_at=datetime.now(timezone.utc),
119+
updated_at=datetime.now(timezone.utc),
120+
project_id=project2_id,
121+
)
122+
123+
# Index both items in their respective projects
124+
await repo1.index_item(search_row1)
125+
await repo2.index_item(search_row2)
126+
127+
# Verify both projects can find their respective notes
128+
results1_before = await repo1.search(search_text="project 1 content")
129+
assert len(results1_before) == 1
130+
assert results1_before[0].title == "Test Note in Project 1"
131+
assert results1_before[0].project_id == project1_id
132+
133+
results2_before = await repo2.search(search_text="project 2 content")
134+
assert len(results2_before) == 1
135+
assert results2_before[0].title == "Test Note in Project 2"
136+
assert results2_before[0].project_id == project2_id
137+
138+
# Now simulate editing the note in project 1 (which re-indexes it)
139+
# This would trigger the bug where the DELETE query doesn't filter by project_id
140+
edited_search_row1 = SearchIndexRow(
141+
id=1,
142+
type=SearchItemType.ENTITY.value,
143+
title="Test Note in Project 1",
144+
content_stems="project 1 content EDITED", # Changed content
145+
content_snippet="This is the EDITED content in project 1",
146+
permalink=same_permalink,
147+
file_path="notes/test_note.md",
148+
entity_id=1,
149+
metadata={"entity_type": "note"},
150+
created_at=datetime.now(timezone.utc),
151+
updated_at=datetime.now(timezone.utc),
152+
project_id=project1_id,
153+
)
154+
155+
# Re-index the edited note in project 1
156+
# BEFORE THE FIX: This would delete the note from project 2 as well!
157+
await repo1.index_item(edited_search_row1)
158+
159+
# Verify project 1 has the edited version
160+
results1_after = await repo1.search(search_text="project 1 content EDITED")
161+
assert len(results1_after) == 1
162+
assert results1_after[0].title == "Test Note in Project 1"
163+
assert "EDITED" in results1_after[0].content_snippet
164+
165+
# CRITICAL TEST: Verify project 2's note is still there (the bug would delete it)
166+
results2_after = await repo2.search(search_text="project 2 content")
167+
assert len(results2_after) == 1, "Project 2's note disappeared after editing project 1's note!"
168+
assert results2_after[0].title == "Test Note in Project 2"
169+
assert results2_after[0].project_id == project2_id
170+
assert "original" in results2_after[0].content_snippet # Should still be original
171+
172+
# Double-check: project 1 should not be able to see project 2's note
173+
cross_search = await repo1.search(search_text="project 2 content")
174+
assert len(cross_search) == 0
175+
176+
await engine.dispose()
177+
178+
179+
@pytest.mark.asyncio
180+
async def test_index_item_updates_existing_record_same_project():
181+
"""Test that index_item() correctly updates existing records within the same project."""
182+
from basic_memory import db
183+
from basic_memory.models.project import Project
184+
from basic_memory.repository.search_repository import SearchRepository
185+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
186+
187+
# Create a separate in-memory database for this test
188+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
189+
session_maker = async_sessionmaker(engine, expire_on_commit=False)
190+
191+
# Create the database schema
192+
async with engine.begin() as conn:
193+
await conn.run_sync(db.Base.metadata.create_all)
194+
195+
# Create one project
196+
async with db.scoped_session(session_maker) as session:
197+
project = Project(
198+
name="Test Project",
199+
description="Test project",
200+
path="/test/path",
201+
is_active=True,
202+
is_default=True
203+
)
204+
session.add(project)
205+
await session.flush()
206+
project_id = project.id
207+
await session.commit()
208+
209+
# Create search repository
210+
repo = SearchRepository(session_maker, project_id=project_id)
211+
await repo.init_search_index()
212+
213+
permalink = "test/my-note"
214+
215+
# Create initial note
216+
initial_row = SearchIndexRow(
217+
id=1,
218+
type=SearchItemType.ENTITY.value,
219+
title="My Test Note",
220+
content_stems="initial content here",
221+
content_snippet="This is the initial content",
222+
permalink=permalink,
223+
file_path="test/my_note.md",
224+
entity_id=1,
225+
metadata={"entity_type": "note"},
226+
created_at=datetime.now(timezone.utc),
227+
updated_at=datetime.now(timezone.utc),
228+
project_id=project_id,
229+
)
230+
231+
# Index the initial version
232+
await repo.index_item(initial_row)
233+
234+
# Verify it exists
235+
results_initial = await repo.search(search_text="initial content")
236+
assert len(results_initial) == 1
237+
assert results_initial[0].content_snippet == "This is the initial content"
238+
239+
# Now update the note (simulate an edit)
240+
updated_row = SearchIndexRow(
241+
id=1,
242+
type=SearchItemType.ENTITY.value,
243+
title="My Test Note",
244+
content_stems="updated content here", # Changed
245+
content_snippet="This is the UPDATED content", # Changed
246+
permalink=permalink, # Same permalink
247+
file_path="test/my_note.md",
248+
entity_id=1,
249+
metadata={"entity_type": "note"},
250+
created_at=datetime.now(timezone.utc),
251+
updated_at=datetime.now(timezone.utc),
252+
project_id=project_id,
253+
)
254+
255+
# Re-index (should replace the old version)
256+
await repo.index_item(updated_row)
257+
258+
# Verify the old version is gone
259+
results_old = await repo.search(search_text="initial content")
260+
assert len(results_old) == 0
261+
262+
# Verify the new version exists
263+
results_new = await repo.search(search_text="updated content")
264+
assert len(results_new) == 1
265+
assert results_new[0].content_snippet == "This is the UPDATED content"
266+
267+
# Verify we only have one record (not duplicated)
268+
all_results = await repo.search(search_text="My Test Note")
269+
assert len(all_results) == 1
270+
271+
await engine.dispose()

0 commit comments

Comments
 (0)