Skip to content

Commit a4000f6

Browse files
jope-bmclaudephernandez
authored
feat: add stable external_id (UUID) to Project and Entity models (#485)
Signed-off-by: Joe P <[email protected]> Signed-off-by: Paul Hernandez <[email protected]> Signed-off-by: phernandez <[email protected]> Co-authored-by: Claude Opus 4.5 <[email protected]> Co-authored-by: Paul Hernandez <[email protected]> Co-authored-by: phernandez <[email protected]>
1 parent 88a1778 commit a4000f6

40 files changed

+1062
-465
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,
@@ -111,6 +113,7 @@ async def update_project(
111113
old_project=old_project_info,
112114
new_project=ProjectItem(
113115
id=updated_project.id,
116+
external_id=updated_project.external_id,
114117
name=updated_project.name,
115118
path=updated_project.path,
116119
is_default=updated_project.is_default or False,
@@ -203,6 +206,7 @@ async def list_projects(
203206
project_items = [
204207
ProjectItem(
205208
id=project.id,
209+
external_id=project.external_id,
206210
name=project.name,
207211
path=normalize_project_path(project.path),
208212
is_default=project.is_default or False,
@@ -250,6 +254,7 @@ async def add_project(
250254
default=existing_project.is_default or False,
251255
new_project=ProjectItem(
252256
id=existing_project.id,
257+
external_id=existing_project.external_id,
253258
name=existing_project.name,
254259
path=existing_project.path,
255260
is_default=existing_project.is_default or False,
@@ -279,6 +284,7 @@ async def add_project(
279284
default=project_data.set_default,
280285
new_project=ProjectItem(
281286
id=new_project.id,
287+
external_id=new_project.external_id,
282288
name=new_project.name,
283289
path=new_project.path,
284290
is_default=new_project.is_default or False,
@@ -334,6 +340,7 @@ async def remove_project(
334340
default=False,
335341
old_project=ProjectItem(
336342
id=old_project.id,
343+
external_id=old_project.external_id,
337344
name=old_project.name,
338345
path=old_project.path,
339346
is_default=old_project.is_default or False,
@@ -382,12 +389,14 @@ async def set_default_project(
382389
default=True,
383390
old_project=ProjectItem(
384391
id=default_project.id,
392+
external_id=default_project.external_id,
385393
name=default_name,
386394
path=default_project.path,
387395
is_default=False,
388396
),
389397
new_project=ProjectItem(
390398
id=new_default_project.id,
399+
external_id=new_default_project.external_id,
391400
name=name,
392401
path=new_default_project.path,
393402
is_default=True,
@@ -417,6 +426,7 @@ async def get_default_project(
417426

418427
return ProjectItem(
419428
id=default_project.id,
429+
external_id=default_project.external_id,
420430
name=default_project.name,
421431
path=default_project.path,
422432
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)