Skip to content

Commit d8e2566

Browse files
phernandezclaude
andcommitted
rename entity_type to note_type across the codebase
The `Entity.entity_type` column stores the frontmatter `type` value (note, spec, schema, person) but its name collides with `SearchItemType` (entity/observation/relation). This caused real bugs where `search_by_metadata({"entity_type": "spec"})` would fail because the metadata filter looked in the wrong JSON column. Changes: - Alembic migration renames column + index on entity table, updates search_index JSON metadata (both SQLite and Postgres) - ORM model, Pydantic schemas, services, repositories, API routers, MCP tools/clients, CLI commands, and schema inference engine all updated to use `note_type` - `SearchQuery.types` renamed to `SearchQuery.note_types` for clarity - Type alias `EntityType` renamed to `NoteType` - ~52 test files updated Unchanged: `SearchItemType` enum, `entity_types` params that filter by entity/observation/relation, frontmatter YAML `type:` key, `entity_metadata` column. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent e1cccba commit d8e2566

86 files changed

Lines changed: 764 additions & 597 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Rename entity_type column to note_type
2+
3+
Revision ID: j3d4e5f6g7h8
4+
Revises: i2c3d4e5f6g7
5+
Create Date: 2026-02-22 12:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
from sqlalchemy import text
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "j3d4e5f6g7h8"
16+
down_revision: Union[str, None] = "i2c3d4e5f6g7"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def table_exists(connection, table_name: str) -> bool:
22+
"""Check if a table exists (idempotent migration support)."""
23+
if connection.dialect.name == "postgresql":
24+
result = connection.execute(
25+
text(
26+
"SELECT 1 FROM information_schema.tables "
27+
"WHERE table_name = :table_name"
28+
),
29+
{"table_name": table_name},
30+
)
31+
return result.fetchone() is not None
32+
# SQLite
33+
result = connection.execute(
34+
text("SELECT 1 FROM sqlite_master WHERE type='table' AND name = :table_name"),
35+
{"table_name": table_name},
36+
)
37+
return result.fetchone() is not None
38+
39+
40+
def index_exists(connection, index_name: str) -> bool:
41+
"""Check if an index exists (idempotent migration support)."""
42+
if connection.dialect.name == "postgresql":
43+
result = connection.execute(
44+
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
45+
{"index_name": index_name},
46+
)
47+
return result.fetchone() is not None
48+
# SQLite
49+
result = connection.execute(
50+
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
51+
{"index_name": index_name},
52+
)
53+
return result.fetchone() is not None
54+
55+
56+
def column_exists(connection, table: str, column: str) -> bool:
57+
"""Check if a column exists in a table (idempotent migration support)."""
58+
if connection.dialect.name == "postgresql":
59+
result = connection.execute(
60+
text(
61+
"SELECT 1 FROM information_schema.columns "
62+
"WHERE table_name = :table AND column_name = :column"
63+
),
64+
{"table": table, "column": column},
65+
)
66+
return result.fetchone() is not None
67+
# SQLite
68+
result = connection.execute(text(f"PRAGMA table_info({table})"))
69+
columns = [row[1] for row in result]
70+
return column in columns
71+
72+
73+
def upgrade() -> None:
74+
"""Rename entity_type → note_type on the entity table."""
75+
connection = op.get_bind()
76+
dialect = connection.dialect.name
77+
78+
# Skip if already migrated (idempotent)
79+
if column_exists(connection, "entity", "note_type"):
80+
return
81+
82+
if dialect == "postgresql":
83+
# Postgres supports direct column rename
84+
op.execute("ALTER TABLE entity RENAME COLUMN entity_type TO note_type")
85+
86+
# Recreate the index with new name
87+
op.execute("DROP INDEX IF EXISTS ix_entity_type")
88+
op.execute("CREATE INDEX ix_note_type ON entity (note_type)")
89+
else:
90+
# SQLite 3.25.0+ supports ALTER TABLE RENAME COLUMN directly.
91+
# Avoids batch_alter_table which fails on tables with generated columns
92+
# (duplicate column name error when recreating the table).
93+
op.execute("ALTER TABLE entity RENAME COLUMN entity_type TO note_type")
94+
95+
# Recreate the index with new name
96+
if index_exists(connection, "ix_entity_type"):
97+
op.drop_index("ix_entity_type", table_name="entity")
98+
op.create_index("ix_note_type", "entity", ["note_type"])
99+
100+
# Update search index metadata: rename entity_type → note_type in JSON
101+
# This updates the stored metadata so search results use the new field name
102+
# Guard: search_index may not exist on a fresh DB (created by an earlier migration)
103+
if not table_exists(connection, "search_index"):
104+
return
105+
106+
if dialect == "postgresql":
107+
op.execute(
108+
text("""
109+
UPDATE search_index
110+
SET metadata = metadata - 'entity_type' || jsonb_build_object('note_type', metadata->'entity_type')
111+
WHERE metadata ? 'entity_type'
112+
""")
113+
)
114+
else:
115+
op.execute(
116+
text("""
117+
UPDATE search_index
118+
SET metadata = json_set(
119+
json_remove(metadata, '$.entity_type'),
120+
'$.note_type',
121+
json_extract(metadata, '$.entity_type')
122+
)
123+
WHERE json_extract(metadata, '$.entity_type') IS NOT NULL
124+
""")
125+
)
126+
127+
128+
def downgrade() -> None:
129+
"""Rename note_type → entity_type on the entity table."""
130+
connection = op.get_bind()
131+
dialect = connection.dialect.name
132+
133+
if dialect == "postgresql":
134+
op.execute("ALTER TABLE entity RENAME COLUMN note_type TO entity_type")
135+
op.execute("DROP INDEX IF EXISTS ix_note_type")
136+
op.execute("CREATE INDEX ix_entity_type ON entity (entity_type)")
137+
else:
138+
op.execute("ALTER TABLE entity RENAME COLUMN note_type TO entity_type")
139+
140+
if index_exists(connection, "ix_note_type"):
141+
op.drop_index("ix_note_type", table_name="entity")
142+
op.create_index("ix_entity_type", "entity", ["entity_type"])
143+
144+
# Revert search index metadata
145+
if not table_exists(connection, "search_index"):
146+
return
147+
148+
if dialect == "postgresql":
149+
op.execute(
150+
text("""
151+
UPDATE search_index
152+
SET metadata = metadata - 'note_type' || jsonb_build_object('entity_type', metadata->'note_type')
153+
WHERE metadata ? 'note_type'
154+
""")
155+
)
156+
else:
157+
op.execute(
158+
text("""
159+
UPDATE search_index
160+
SET metadata = json_set(
161+
json_remove(metadata, '$.note_type'),
162+
'$.entity_type',
163+
json_extract(metadata, '$.note_type')
164+
)
165+
WHERE json_extract(metadata, '$.note_type') IS NOT NULL
166+
""")
167+
)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ async def create_entity(
201201
Created entity with generated external_id (UUID) and file content
202202
"""
203203
logger.info(
204-
"API v2 request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
204+
"API v2 request", endpoint="create_entity", note_type=data.note_type, title=data.title
205205
)
206206

207207
if fast:

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,14 @@ async def create_resource(
145145
# Determine file details
146146
file_name = PathLib(data.file_path).name
147147
content_type = file_service.content_type(data.file_path)
148-
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
148+
note_type = "canvas" if data.file_path.endswith(".canvas") else "file"
149149

150150
# Create a new entity model
151151
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
152152
entity = EntityModel(
153153
external_id=str(uuid.uuid4()),
154154
title=file_name,
155-
entity_type=entity_type,
155+
note_type=note_type,
156156
content_type=content_type,
157157
file_path=data.file_path,
158158
checksum=checksum,
@@ -253,14 +253,14 @@ async def update_resource(
253253
# Determine file details
254254
file_name = PathLib(target_file_path).name
255255
content_type = file_service.content_type(target_file_path)
256-
entity_type = "canvas" if target_file_path.endswith(".canvas") else "file"
256+
note_type = "canvas" if target_file_path.endswith(".canvas") else "file"
257257

258258
# Update entity using internal ID
259259
updated_entity = await entity_repository.update(
260260
entity.id,
261261
{
262262
"title": file_name,
263-
"entity_type": entity_type,
263+
"note_type": note_type,
264264
"content_type": content_type,
265265
"file_path": target_file_path,
266266
"checksum": checksum,

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

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def _entity_relations(entity: Entity) -> list[RelationData]:
5151
RelationData(
5252
relation_type=rel.relation_type,
5353
target_name=rel.to_name,
54-
target_entity_type=rel.to_entity.entity_type if rel.to_entity else None,
54+
target_note_type=rel.to_entity.note_type if rel.to_entity else None,
5555
)
5656
for rel in entity.outgoing_relations
5757
]
@@ -69,8 +69,8 @@ def _entity_to_note_data(entity: Entity) -> NoteData:
6969
def _entity_frontmatter(entity: Entity) -> dict:
7070
"""Build a frontmatter dict from an entity for schema resolution."""
7171
frontmatter = dict(entity.entity_metadata) if entity.entity_metadata else {}
72-
if entity.entity_type:
73-
frontmatter.setdefault("type", entity.entity_type)
72+
if entity.note_type:
73+
frontmatter.setdefault("type", entity.note_type)
7474
return frontmatter
7575

7676

@@ -81,7 +81,7 @@ def _entity_frontmatter(entity: Entity) -> dict:
8181
async def validate_schema(
8282
entity_repository: EntityRepositoryV2ExternalDep,
8383
project_id: str = Path(..., description="Project external UUID"),
84-
entity_type: str | None = Query(None, description="Entity type to validate"),
84+
note_type: str | None = Query(None, description="Note type to validate"),
8585
identifier: str | None = Query(None, description="Specific note identifier"),
8686
):
8787
"""Validate notes against their resolved schemas.
@@ -95,7 +95,7 @@ async def validate_schema(
9595
if identifier:
9696
entity = await entity_repository.get_by_permalink(identifier)
9797
if not entity:
98-
return ValidationReport(entity_type=entity_type, total_notes=0, results=[])
98+
return ValidationReport(note_type=note_type, total_notes=0, results=[])
9999

100100
frontmatter = _entity_frontmatter(entity)
101101
schema_ref = frontmatter.get("schema")
@@ -120,16 +120,16 @@ async def search_fn(query: str) -> list[dict]:
120120
results.append(_to_note_validation_response(result))
121121

122122
return ValidationReport(
123-
entity_type=entity_type or entity.entity_type,
123+
note_type=note_type or entity.note_type,
124124
total_notes=1,
125125
valid_count=1 if (results and results[0].passed) else 0,
126126
warning_count=sum(len(r.warnings) for r in results),
127127
error_count=sum(len(r.errors) for r in results),
128128
results=results,
129129
)
130130

131-
# --- Batch validation by entity type ---
132-
entities = await _find_by_entity_type(entity_repository, entity_type) if entity_type else []
131+
# --- Batch validation by note type ---
132+
entities = await _find_by_note_type(entity_repository, note_type) if note_type else []
133133

134134
for entity in entities:
135135
frontmatter = _entity_frontmatter(entity)
@@ -156,7 +156,7 @@ async def search_fn(query: str) -> list[dict]:
156156

157157
valid = sum(1 for r in results if r.passed)
158158
return ValidationReport(
159-
entity_type=entity_type,
159+
note_type=note_type,
160160
total_notes=len(results),
161161
total_entities=len(entities),
162162
valid_count=valid,
@@ -173,21 +173,21 @@ async def search_fn(query: str) -> list[dict]:
173173
async def infer_schema_endpoint(
174174
entity_repository: EntityRepositoryV2ExternalDep,
175175
project_id: str = Path(..., description="Project external UUID"),
176-
entity_type: str = Query(..., description="Entity type to analyze"),
176+
note_type: str = Query(..., description="Note type to analyze"),
177177
threshold: float = Query(0.25, description="Minimum frequency for optional fields"),
178178
):
179179
"""Infer a schema from existing notes of a given type.
180180
181181
Examines observation categories and relation types across all notes
182182
of the given type. Returns frequency analysis and suggested Picoschema.
183183
"""
184-
entities = await _find_by_entity_type(entity_repository, entity_type)
184+
entities = await _find_by_note_type(entity_repository, note_type)
185185
notes_data = [_entity_to_note_data(entity) for entity in entities]
186186

187-
result = infer_schema(entity_type, notes_data, optional_threshold=threshold)
187+
result = infer_schema(note_type, notes_data, optional_threshold=threshold)
188188

189189
return InferenceReport(
190-
entity_type=result.entity_type,
190+
note_type=result.note_type,
191191
notes_analyzed=result.notes_analyzed,
192192
field_frequencies=[
193193
FieldFrequencyResponse(
@@ -212,10 +212,10 @@ async def infer_schema_endpoint(
212212
# --- Drift Detection ---
213213

214214

215-
@router.get("/schema/diff/{entity_type}", response_model=DriftReport)
215+
@router.get("/schema/diff/{note_type}", response_model=DriftReport)
216216
async def diff_schema_endpoint(
217217
entity_repository: EntityRepositoryV2ExternalDep,
218-
entity_type: str = Path(..., description="Entity type to check for drift"),
218+
note_type: str = Path(..., description="Note type to check for drift"),
219219
project_id: str = Path(..., description="Project external UUID"),
220220
):
221221
"""Show drift between a schema definition and actual note usage.
@@ -229,21 +229,21 @@ async def search_fn(query: str) -> list[dict]:
229229
entities = await _find_schema_entities(entity_repository, query)
230230
return [_entity_frontmatter(e) for e in entities]
231231

232-
# Resolve schema by entity type
233-
schema_frontmatter = {"type": entity_type}
232+
# Resolve schema by note type
233+
schema_frontmatter = {"type": note_type}
234234
schema_def = await resolve_schema(schema_frontmatter, search_fn)
235235

236236
if not schema_def:
237-
return DriftReport(entity_type=entity_type, schema_found=False)
237+
return DriftReport(note_type=note_type, schema_found=False)
238238

239239
# Collect all notes of this type
240-
entities = await _find_by_entity_type(entity_repository, entity_type)
240+
entities = await _find_by_note_type(entity_repository, note_type)
241241
notes_data = [_entity_to_note_data(entity) for entity in entities]
242242

243243
result = diff_schema(schema_def, notes_data)
244244

245245
return DriftReport(
246-
entity_type=entity_type,
246+
note_type=note_type,
247247
new_fields=[
248248
DriftFieldResponse(
249249
name=f.name,
@@ -271,19 +271,19 @@ async def search_fn(query: str) -> list[dict]:
271271
# --- Helpers ---
272272

273273

274-
async def _find_by_entity_type(
274+
async def _find_by_note_type(
275275
entity_repository: EntityRepositoryV2ExternalDep,
276-
entity_type: str,
276+
note_type: str,
277277
) -> list[Entity]:
278278
"""Find all entities of a given type using the repository's select pattern."""
279-
query = entity_repository.select().where(Entity.entity_type == entity_type)
279+
query = entity_repository.select().where(Entity.note_type == note_type)
280280
result = await entity_repository.execute_query(query)
281281
return list(result.scalars().all())
282282

283283

284284
async def _find_schema_entities(
285285
entity_repository: EntityRepositoryV2ExternalDep,
286-
target_entity_type: str,
286+
target_note_type: str,
287287
*,
288288
allow_reference_match: bool = False,
289289
) -> list[Entity]:
@@ -295,11 +295,11 @@ async def _find_schema_entities(
295295
2) Only when allow_reference_match=True and no entity match was found, try
296296
exact reference matching by title/permalink (explicit schema references)
297297
"""
298-
query = entity_repository.select().where(Entity.entity_type == "schema")
298+
query = entity_repository.select().where(Entity.note_type == "schema")
299299
result = await entity_repository.execute_query(query)
300300
entities = list(result.scalars().all())
301301

302-
normalized_target = generate_permalink(target_entity_type)
302+
normalized_target = generate_permalink(target_note_type)
303303

304304
entity_matches = [
305305
e

src/basic_memory/cli/commands/doctor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ async def run_doctor() -> None:
6161
api_note = Entity(
6262
title=api_note_title,
6363
directory="doctor",
64-
entity_type="note",
64+
note_type="note",
6565
content_type="text/markdown",
6666
content=f"# {api_note_title}\n\n- [note] API to file check",
6767
entity_metadata={"tags": ["doctor"]},

0 commit comments

Comments
 (0)