Skip to content

Commit 731da47

Browse files
phernandezclaude
andcommitted
Merge branch 'main' into test-coverage-and-integration
Resolved conflicts: - knowledge_router.py: Remove duplicate resolution_method logic, use consistent error messages - project_router.py: Use consistent 'external_id' error message format - write_note.py: Use active_project.external_id for v2 API (correct identifier) - test_cli_tool_exit.py: Combine imports (os + platform), use SUBPROCESS_TIMEOUT with env - test_project_add_with_local_path.py: Keep monkeypatch-based mock (remove stdlib mocks) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
2 parents 100c328 + a4000f6 commit 731da47

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1117
-477
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Merge multiple heads
2+
3+
Revision ID: 6830751f5fb6
4+
Revises: a2b3c4d5e6f7, g9a0b3c4d5e6
5+
Create Date: 2025-12-29 12:46:46.476268
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '6830751f5fb6'
16+
down_revision: Union[str, Sequence[str], None] = ('a2b3c4d5e6f7', 'g9a0b3c4d5e6')
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
pass
23+
24+
25+
def downgrade() -> None:
26+
pass
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Add external_id UUID column to project and entity tables
2+
3+
Revision ID: g9a0b3c4d5e6
4+
Revises: f8a9b2c3d4e5
5+
Create Date: 2025-12-29 10:00:00.000000
6+
7+
"""
8+
9+
import uuid
10+
from typing import Sequence, Union
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
from sqlalchemy import text
15+
16+
17+
def column_exists(connection, table: str, column: str) -> bool:
18+
"""Check if a column exists in a table (idempotent migration support)."""
19+
if connection.dialect.name == "postgresql":
20+
result = connection.execute(
21+
text(
22+
"SELECT 1 FROM information_schema.columns "
23+
"WHERE table_name = :table AND column_name = :column"
24+
),
25+
{"table": table, "column": column},
26+
)
27+
return result.fetchone() is not None
28+
else:
29+
# SQLite
30+
result = connection.execute(text(f"PRAGMA table_info({table})"))
31+
columns = [row[1] for row in result]
32+
return column in columns
33+
34+
35+
def index_exists(connection, index_name: str) -> bool:
36+
"""Check if an index exists (idempotent migration support)."""
37+
if connection.dialect.name == "postgresql":
38+
result = connection.execute(
39+
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
40+
{"index_name": index_name},
41+
)
42+
return result.fetchone() is not None
43+
else:
44+
# SQLite
45+
result = connection.execute(
46+
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
47+
{"index_name": index_name},
48+
)
49+
return result.fetchone() is not None
50+
51+
52+
# revision identifiers, used by Alembic.
53+
revision: str = "g9a0b3c4d5e6"
54+
down_revision: Union[str, None] = "f8a9b2c3d4e5"
55+
branch_labels: Union[str, Sequence[str], None] = None
56+
depends_on: Union[str, Sequence[str], None] = None
57+
58+
59+
def upgrade() -> None:
60+
"""Add external_id UUID column to project and entity tables.
61+
62+
This migration:
63+
1. Adds external_id column to project table
64+
2. Adds external_id column to entity table
65+
3. Generates UUIDs for existing rows
66+
4. Creates unique indexes on both columns
67+
"""
68+
connection = op.get_bind()
69+
dialect = connection.dialect.name
70+
71+
# -------------------------------------------------------------------------
72+
# Add external_id to project table
73+
# -------------------------------------------------------------------------
74+
75+
if not column_exists(connection, "project", "external_id"):
76+
# Step 1: Add external_id column as nullable first
77+
op.add_column("project", sa.Column("external_id", sa.String(), nullable=True))
78+
79+
# Step 2: Generate UUIDs for existing rows
80+
if dialect == "postgresql":
81+
# Postgres has gen_random_uuid() function
82+
op.execute("""
83+
UPDATE project
84+
SET external_id = gen_random_uuid()::text
85+
WHERE external_id IS NULL
86+
""")
87+
else:
88+
# SQLite: need to generate UUIDs in Python
89+
result = connection.execute(text("SELECT id FROM project WHERE external_id IS NULL"))
90+
for row in result:
91+
new_uuid = str(uuid.uuid4())
92+
connection.execute(
93+
text("UPDATE project SET external_id = :uuid WHERE id = :id"),
94+
{"uuid": new_uuid, "id": row[0]},
95+
)
96+
97+
# Step 3: Make external_id NOT NULL
98+
if dialect == "postgresql":
99+
op.alter_column("project", "external_id", nullable=False)
100+
else:
101+
# SQLite requires batch operations for ALTER COLUMN
102+
with op.batch_alter_table("project") as batch_op:
103+
batch_op.alter_column("external_id", nullable=False)
104+
105+
# Step 4: Create unique index on project.external_id (idempotent)
106+
if not index_exists(connection, "ix_project_external_id"):
107+
op.create_index("ix_project_external_id", "project", ["external_id"], unique=True)
108+
109+
# -------------------------------------------------------------------------
110+
# Add external_id to entity table
111+
# -------------------------------------------------------------------------
112+
113+
if not column_exists(connection, "entity", "external_id"):
114+
# Step 1: Add external_id column as nullable first
115+
op.add_column("entity", sa.Column("external_id", sa.String(), nullable=True))
116+
117+
# Step 2: Generate UUIDs for existing rows
118+
if dialect == "postgresql":
119+
# Postgres has gen_random_uuid() function
120+
op.execute("""
121+
UPDATE entity
122+
SET external_id = gen_random_uuid()::text
123+
WHERE external_id IS NULL
124+
""")
125+
else:
126+
# SQLite: need to generate UUIDs in Python
127+
result = connection.execute(text("SELECT id FROM entity WHERE external_id IS NULL"))
128+
for row in result:
129+
new_uuid = str(uuid.uuid4())
130+
connection.execute(
131+
text("UPDATE entity SET external_id = :uuid WHERE id = :id"),
132+
{"uuid": new_uuid, "id": row[0]},
133+
)
134+
135+
# Step 3: Make external_id NOT NULL
136+
if dialect == "postgresql":
137+
op.alter_column("entity", "external_id", nullable=False)
138+
else:
139+
# SQLite requires batch operations for ALTER COLUMN
140+
with op.batch_alter_table("entity") as batch_op:
141+
batch_op.alter_column("external_id", nullable=False)
142+
143+
# Step 4: Create unique index on entity.external_id (idempotent)
144+
if not index_exists(connection, "ix_entity_external_id"):
145+
op.create_index("ix_entity_external_id", "entity", ["external_id"], unique=True)
146+
147+
148+
def downgrade() -> None:
149+
"""Remove external_id columns from project and entity tables."""
150+
connection = op.get_bind()
151+
dialect = connection.dialect.name
152+
153+
# Drop from entity table
154+
if index_exists(connection, "ix_entity_external_id"):
155+
op.drop_index("ix_entity_external_id", table_name="entity")
156+
157+
if column_exists(connection, "entity", "external_id"):
158+
if dialect == "postgresql":
159+
op.drop_column("entity", "external_id")
160+
else:
161+
with op.batch_alter_table("entity") as batch_op:
162+
batch_op.drop_column("external_id")
163+
164+
# Drop from project table
165+
if index_exists(connection, "ix_project_external_id"):
166+
op.drop_index("ix_project_external_id", table_name="project")
167+
168+
if column_exists(connection, "project", "external_id"):
169+
if dialect == "postgresql":
170+
op.drop_column("project", "external_id")
171+
else:
172+
with op.batch_alter_table("project") as batch_op:
173+
batch_op.drop_column("external_id")

src/basic_memory/api/routers/project_router.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ async def get_project(
5151

5252
return ProjectItem(
5353
id=found_project.id,
54+
external_id=found_project.external_id,
5455
name=found_project.name,
5556
path=normalize_project_path(found_project.path),
5657
is_default=found_project.is_default or False,
@@ -89,6 +90,7 @@ async def update_project(
8990

9091
old_project_info = ProjectItem(
9192
id=old_project.id,
93+
external_id=old_project.external_id,
9294
name=old_project.name,
9395
path=old_project.path,
9496
is_default=old_project.is_default or False,
@@ -113,6 +115,7 @@ async def update_project(
113115
old_project=old_project_info,
114116
new_project=ProjectItem(
115117
id=updated_project.id,
118+
external_id=updated_project.external_id,
116119
name=updated_project.name,
117120
path=updated_project.path,
118121
is_default=updated_project.is_default or False,
@@ -205,6 +208,7 @@ async def list_projects(
205208
project_items = [
206209
ProjectItem(
207210
id=project.id,
211+
external_id=project.external_id,
208212
name=project.name,
209213
path=normalize_project_path(project.path),
210214
is_default=project.is_default or False,
@@ -252,6 +256,7 @@ async def add_project(
252256
default=existing_project.is_default or False,
253257
new_project=ProjectItem(
254258
id=existing_project.id,
259+
external_id=existing_project.external_id,
255260
name=existing_project.name,
256261
path=existing_project.path,
257262
is_default=existing_project.is_default or False,
@@ -281,6 +286,7 @@ async def add_project(
281286
default=project_data.set_default,
282287
new_project=ProjectItem(
283288
id=new_project.id,
289+
external_id=new_project.external_id,
284290
name=new_project.name,
285291
path=new_project.path,
286292
is_default=new_project.is_default or False,
@@ -336,6 +342,7 @@ async def remove_project(
336342
default=False,
337343
old_project=ProjectItem(
338344
id=old_project.id,
345+
external_id=old_project.external_id,
339346
name=old_project.name,
340347
path=old_project.path,
341348
is_default=old_project.is_default or False,
@@ -384,12 +391,14 @@ async def set_default_project(
384391
default=True,
385392
old_project=ProjectItem(
386393
id=default_project.id,
394+
external_id=default_project.external_id,
387395
name=default_name,
388396
path=default_project.path,
389397
is_default=False,
390398
),
391399
new_project=ProjectItem(
392400
id=new_default_project.id,
401+
external_id=new_default_project.external_id,
393402
name=name,
394403
path=new_default_project.path,
395404
is_default=True,
@@ -419,6 +428,7 @@ async def get_default_project(
419428

420429
return ProjectItem(
421430
id=default_project.id,
431+
external_id=default_project.external_id,
422432
name=default_project.name,
423433
path=default_project.path,
424434
is_default=True,

src/basic_memory/api/v2/routers/directory_router.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,34 @@
11
"""V2 Directory Router - ID-based directory tree operations.
22
33
This router provides directory structure browsing for projects using
4-
integer project IDs instead of name-based identifiers.
4+
external_id UUIDs instead of name-based identifiers.
55
66
Key improvements:
7-
- Direct project lookup via integer primary keys
7+
- Direct project lookup via external_id UUIDs
88
- Consistent with other v2 endpoints
99
- Better performance through indexed queries
1010
"""
1111

1212
from typing import List, Optional
1313

14-
from fastapi import APIRouter, Query
14+
from fastapi import APIRouter, Query, Path
1515

16-
from basic_memory.deps import DirectoryServiceV2Dep, ProjectIdPathDep
16+
from basic_memory.deps import DirectoryServiceV2ExternalDep
1717
from basic_memory.schemas.directory import DirectoryNode
1818

1919
router = APIRouter(prefix="/directory", tags=["directory-v2"])
2020

2121

2222
@router.get("/tree", response_model=DirectoryNode, response_model_exclude_none=True)
2323
async def get_directory_tree(
24-
directory_service: DirectoryServiceV2Dep,
25-
project_id: ProjectIdPathDep,
24+
directory_service: DirectoryServiceV2ExternalDep,
25+
project_id: str = Path(..., description="Project external UUID"),
2626
):
2727
"""Get hierarchical directory structure from the knowledge base.
2828
2929
Args:
3030
directory_service: Service for directory operations
31-
project_id: Numeric project ID
31+
project_id: Project external UUID
3232
3333
Returns:
3434
DirectoryNode representing the root of the hierarchical tree structure
@@ -42,8 +42,8 @@ async def get_directory_tree(
4242

4343
@router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
4444
async def get_directory_structure(
45-
directory_service: DirectoryServiceV2Dep,
46-
project_id: ProjectIdPathDep,
45+
directory_service: DirectoryServiceV2ExternalDep,
46+
project_id: str = Path(..., description="Project external UUID"),
4747
):
4848
"""Get folder structure for navigation (no files).
4949
@@ -52,7 +52,7 @@ async def get_directory_structure(
5252
5353
Args:
5454
directory_service: Service for directory operations
55-
project_id: Numeric project ID
55+
project_id: Project external UUID
5656
5757
Returns:
5858
DirectoryNode tree containing only folders (type="directory")
@@ -63,8 +63,8 @@ async def get_directory_structure(
6363

6464
@router.get("/list", response_model=List[DirectoryNode], response_model_exclude_none=True)
6565
async def list_directory(
66-
directory_service: DirectoryServiceV2Dep,
67-
project_id: ProjectIdPathDep,
66+
directory_service: DirectoryServiceV2ExternalDep,
67+
project_id: str = Path(..., description="Project external UUID"),
6868
dir_name: str = Query("/", description="Directory path to list"),
6969
depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"),
7070
file_name_glob: Optional[str] = Query(
@@ -75,7 +75,7 @@ async def list_directory(
7575
7676
Args:
7777
directory_service: Service for directory operations
78-
project_id: Numeric project ID
78+
project_id: Project external UUID
7979
dir_name: Directory path to list (default: root "/")
8080
depth: Recursion depth (1-10, default: 1 for immediate children only)
8181
file_name_glob: Optional glob pattern for filtering file names (e.g., "*.md", "*meeting*")

0 commit comments

Comments
 (0)