mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e10e21a6c |
@@ -59,16 +59,27 @@ class EntityRepository(Repository[Entity]):
|
|||||||
)
|
)
|
||||||
return await self.find_one(query)
|
return await self.find_one(query)
|
||||||
|
|
||||||
async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
|
async def _find_one_by_query(self, query, *, load_relations: bool) -> Optional[Entity]:
|
||||||
|
"""Return one entity row with optional eager loading."""
|
||||||
|
if load_relations:
|
||||||
|
query = query.options(*self.get_load_options())
|
||||||
|
return await self.find_one(query)
|
||||||
|
|
||||||
|
result = await self.execute_query(query, use_query_options=False)
|
||||||
|
return result.scalars().one_or_none()
|
||||||
|
|
||||||
|
async def get_by_permalink(
|
||||||
|
self, permalink: str, *, load_relations: bool = True
|
||||||
|
) -> Optional[Entity]:
|
||||||
"""Get entity by permalink.
|
"""Get entity by permalink.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
permalink: Unique identifier for the entity
|
permalink: Unique identifier for the entity
|
||||||
"""
|
"""
|
||||||
query = self.select().where(Entity.permalink == permalink).options(*self.get_load_options())
|
query = self.select().where(Entity.permalink == permalink)
|
||||||
return await self.find_one(query)
|
return await self._find_one_by_query(query, load_relations=load_relations)
|
||||||
|
|
||||||
async def get_by_title(self, title: str) -> Sequence[Entity]:
|
async def get_by_title(self, title: str, *, load_relations: bool = True) -> Sequence[Entity]:
|
||||||
"""Get entities by title, ordered by shortest path first.
|
"""Get entities by title, ordered by shortest path first.
|
||||||
|
|
||||||
When multiple entities share the same title (in different folders),
|
When multiple entities share the same title (in different folders),
|
||||||
@@ -82,23 +93,20 @@ class EntityRepository(Repository[Entity]):
|
|||||||
self.select()
|
self.select()
|
||||||
.where(Entity.title == title)
|
.where(Entity.title == title)
|
||||||
.order_by(func.length(Entity.file_path), Entity.file_path)
|
.order_by(func.length(Entity.file_path), Entity.file_path)
|
||||||
.options(*self.get_load_options())
|
|
||||||
)
|
)
|
||||||
result = await self.execute_query(query)
|
result = await self.execute_query(query, use_query_options=load_relations)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
async def get_by_file_path(self, file_path: Union[Path, str]) -> Optional[Entity]:
|
async def get_by_file_path(
|
||||||
|
self, file_path: Union[Path, str], *, load_relations: bool = True
|
||||||
|
) -> Optional[Entity]:
|
||||||
"""Get entity by file_path.
|
"""Get entity by file_path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the entity file (will be converted to string internally)
|
file_path: Path to the entity file (will be converted to string internally)
|
||||||
"""
|
"""
|
||||||
query = (
|
query = self.select().where(Entity.file_path == Path(file_path).as_posix())
|
||||||
self.select()
|
return await self._find_one_by_query(query, load_relations=load_relations)
|
||||||
.where(Entity.file_path == Path(file_path).as_posix())
|
|
||||||
.options(*self.get_load_options())
|
|
||||||
)
|
|
||||||
return await self.find_one(query)
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Lightweight methods for permalink resolution (no eager loading)
|
# Lightweight methods for permalink resolution (no eager loading)
|
||||||
@@ -306,7 +314,7 @@ class EntityRepository(Repository[Entity]):
|
|||||||
result = await self.execute_query(query)
|
result = await self.execute_query(query)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
async def upsert_entity(self, entity: Entity) -> Entity:
|
async def upsert_entity(self, entity: Entity, *, reload: bool = True) -> Entity:
|
||||||
"""Insert or update entity using simple try/catch with database-level conflict resolution.
|
"""Insert or update entity using simple try/catch with database-level conflict resolution.
|
||||||
|
|
||||||
Handles file_path race conditions by checking for existing entity on IntegrityError.
|
Handles file_path race conditions by checking for existing entity on IntegrityError.
|
||||||
@@ -327,6 +335,9 @@ class EntityRepository(Repository[Entity]):
|
|||||||
session.add(entity)
|
session.add(entity)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
|
if not reload:
|
||||||
|
return entity
|
||||||
|
|
||||||
# Return with relationships loaded
|
# Return with relationships loaded
|
||||||
query = (
|
query = (
|
||||||
self.select()
|
self.select()
|
||||||
@@ -363,13 +374,12 @@ class EntityRepository(Repository[Entity]):
|
|||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
# Re-query after rollback to get a fresh, attached entity
|
# Re-query after rollback to get a fresh, attached entity
|
||||||
existing_result = await session.execute(
|
existing_query = select(Entity).where(
|
||||||
select(Entity)
|
Entity.file_path == entity.file_path, Entity.project_id == entity.project_id
|
||||||
.where(
|
|
||||||
Entity.file_path == entity.file_path, Entity.project_id == entity.project_id
|
|
||||||
)
|
|
||||||
.options(*self.get_load_options())
|
|
||||||
)
|
)
|
||||||
|
if reload:
|
||||||
|
existing_query = existing_query.options(*self.get_load_options())
|
||||||
|
existing_result = await session.execute(existing_query)
|
||||||
existing_entity = existing_result.scalar_one_or_none()
|
existing_entity = existing_result.scalar_one_or_none()
|
||||||
|
|
||||||
if existing_entity:
|
if existing_entity:
|
||||||
@@ -393,6 +403,9 @@ class EntityRepository(Repository[Entity]):
|
|||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
if not reload:
|
||||||
|
return merged_entity
|
||||||
|
|
||||||
# Re-query to get proper relationships loaded
|
# Re-query to get proper relationships loaded
|
||||||
final_result = await session.execute(
|
final_result = await session.execute(
|
||||||
select(Entity)
|
select(Entity)
|
||||||
|
|||||||
@@ -268,8 +268,21 @@ class Repository[T: Base]:
|
|||||||
|
|
||||||
return await self.select_by_ids(session, [model.id for model in model_list]) # pyright: ignore [reportAttributeAccessIssue]
|
return await self.select_by_ids(session, [model.id for model in model_list]) # pyright: ignore [reportAttributeAccessIssue]
|
||||||
|
|
||||||
async def update(self, entity_id: int, entity_data: dict | T) -> Optional[T]:
|
async def update(
|
||||||
"""Update an entity with the given data."""
|
self,
|
||||||
|
entity_id: int,
|
||||||
|
entity_data: dict | T,
|
||||||
|
*,
|
||||||
|
reload: bool = True,
|
||||||
|
) -> Optional[T]:
|
||||||
|
"""Update an entity with the given data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity_id: Primary key to update
|
||||||
|
entity_data: Column values or a model instance to copy from
|
||||||
|
reload: When True, re-select the entity with repository load options.
|
||||||
|
When False, return the attached row after flush/refresh.
|
||||||
|
"""
|
||||||
logger.debug(f"Updating {self.Model.__name__} {entity_id} with data: {entity_data}")
|
logger.debug(f"Updating {self.Model.__name__} {entity_id} with data: {entity_data}")
|
||||||
async with db.scoped_session(self.session_maker) as session:
|
async with db.scoped_session(self.session_maker) as session:
|
||||||
try:
|
try:
|
||||||
@@ -291,6 +304,8 @@ class Repository[T: Base]:
|
|||||||
await session.refresh(entity) # Refresh
|
await session.refresh(entity) # Refresh
|
||||||
|
|
||||||
logger.debug(f"Updated {self.Model.__name__}: {entity_id}")
|
logger.debug(f"Updated {self.Model.__name__}: {entity_id}")
|
||||||
|
if not reload:
|
||||||
|
return entity
|
||||||
return await self.select_by_id(session, entity.id) # pyright: ignore [reportAttributeAccessIssue]
|
return await self.select_by_id(session, entity.id) # pyright: ignore [reportAttributeAccessIssue]
|
||||||
|
|
||||||
except NoResultFound:
|
except NoResultFound:
|
||||||
|
|||||||
@@ -242,9 +242,17 @@ class EntityService(BaseService[EntityModel]):
|
|||||||
|
|
||||||
# Try to find existing entity using strict resolution (no fuzzy search)
|
# Try to find existing entity using strict resolution (no fuzzy search)
|
||||||
# This prevents incorrectly matching similar file paths like "Node A.md" and "Node C.md"
|
# This prevents incorrectly matching similar file paths like "Node A.md" and "Node C.md"
|
||||||
existing = await self.link_resolver.resolve_link(schema.file_path, strict=True)
|
existing = await self.link_resolver.resolve_link(
|
||||||
|
schema.file_path,
|
||||||
|
strict=True,
|
||||||
|
load_relations=False,
|
||||||
|
)
|
||||||
if not existing and schema.permalink:
|
if not existing and schema.permalink:
|
||||||
existing = await self.link_resolver.resolve_link(schema.permalink, strict=True)
|
existing = await self.link_resolver.resolve_link(
|
||||||
|
schema.permalink,
|
||||||
|
strict=True,
|
||||||
|
load_relations=False,
|
||||||
|
)
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
logger.debug(f"Found existing entity: {existing.file_path}")
|
logger.debug(f"Found existing entity: {existing.file_path}")
|
||||||
@@ -324,17 +332,14 @@ class EntityService(BaseService[EntityModel]):
|
|||||||
action="create",
|
action="create",
|
||||||
phase="upsert_entity",
|
phase="upsert_entity",
|
||||||
):
|
):
|
||||||
entity = await self.upsert_entity_from_markdown(file_path, entity_markdown, is_new=True)
|
updated = await self.upsert_entity_from_markdown(
|
||||||
|
file_path,
|
||||||
with telemetry.scope(
|
entity_markdown,
|
||||||
"entity_service.create.update_checksum",
|
is_new=True,
|
||||||
domain="entity_service",
|
checksum=checksum,
|
||||||
action="create",
|
)
|
||||||
phase="update_checksum",
|
|
||||||
):
|
|
||||||
updated = await self.repository.update(entity.id, {"checksum": checksum})
|
|
||||||
if not updated: # pragma: no cover
|
if not updated: # pragma: no cover
|
||||||
raise ValueError(f"Failed to update entity checksum after create: {entity.id}")
|
raise ValueError(f"Failed to persist entity after create: {file_path}")
|
||||||
return EntityWriteResult(
|
return EntityWriteResult(
|
||||||
entity=updated,
|
entity=updated,
|
||||||
content=final_content,
|
content=final_content,
|
||||||
@@ -451,18 +456,13 @@ class EntityService(BaseService[EntityModel]):
|
|||||||
phase="upsert_entity",
|
phase="upsert_entity",
|
||||||
):
|
):
|
||||||
entity = await self.upsert_entity_from_markdown(
|
entity = await self.upsert_entity_from_markdown(
|
||||||
file_path, entity_markdown, is_new=False
|
file_path,
|
||||||
|
entity_markdown,
|
||||||
|
is_new=False,
|
||||||
|
checksum=checksum,
|
||||||
)
|
)
|
||||||
|
|
||||||
with telemetry.scope(
|
|
||||||
"entity_service.update.update_checksum",
|
|
||||||
domain="entity_service",
|
|
||||||
action="update",
|
|
||||||
phase="update_checksum",
|
|
||||||
):
|
|
||||||
entity = await self.repository.update(entity.id, {"checksum": checksum})
|
|
||||||
if not entity: # pragma: no cover
|
if not entity: # pragma: no cover
|
||||||
raise ValueError(f"Failed to update entity checksum after update: {file_path}")
|
raise ValueError(f"Failed to persist entity after update: {file_path}")
|
||||||
|
|
||||||
return EntityWriteResult(
|
return EntityWriteResult(
|
||||||
entity=entity,
|
entity=entity,
|
||||||
@@ -848,7 +848,7 @@ class EntityService(BaseService[EntityModel]):
|
|||||||
|
|
||||||
# Use UPSERT to handle conflicts cleanly
|
# Use UPSERT to handle conflicts cleanly
|
||||||
try:
|
try:
|
||||||
return await self.repository.upsert_entity(model)
|
return await self.repository.upsert_entity(model, reload=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to upsert entity for {file_path}: {e}")
|
logger.error(f"Failed to upsert entity for {file_path}: {e}")
|
||||||
raise EntityCreationError(f"Failed to create entity: {str(e)}") from e
|
raise EntityCreationError(f"Failed to create entity: {str(e)}") from e
|
||||||
@@ -863,7 +863,12 @@ class EntityService(BaseService[EntityModel]):
|
|||||||
"""
|
"""
|
||||||
logger.debug(f"Updating entity and observations: {file_path}")
|
logger.debug(f"Updating entity and observations: {file_path}")
|
||||||
|
|
||||||
db_entity = await self.repository.get_by_file_path(file_path.as_posix())
|
db_entity = await self.repository.get_by_file_path(
|
||||||
|
file_path.as_posix(),
|
||||||
|
load_relations=False,
|
||||||
|
)
|
||||||
|
if not db_entity: # pragma: no cover
|
||||||
|
raise EntityNotFoundError(f"Entity not found: {file_path}")
|
||||||
|
|
||||||
# Clear observations for entity
|
# Clear observations for entity
|
||||||
await self.observation_repository.delete_by_fields(entity_id=db_entity.id)
|
await self.observation_repository.delete_by_fields(entity_id=db_entity.id)
|
||||||
@@ -880,23 +885,37 @@ class EntityService(BaseService[EntityModel]):
|
|||||||
)
|
)
|
||||||
for obs in markdown.observations
|
for obs in markdown.observations
|
||||||
]
|
]
|
||||||
await self.observation_repository.add_all(observations)
|
if observations:
|
||||||
|
await self.observation_repository.add_all(observations)
|
||||||
|
|
||||||
# update values from markdown
|
# Trigger: the lightweight lookup above returns a detached row without loaded collections
|
||||||
db_entity = entity_model_from_markdown(file_path, markdown, db_entity)
|
# Why: assigning a new observation list onto that detached ORM object would trigger lazy loads
|
||||||
|
# Outcome: rebuild a fresh model from markdown, then copy over stable identity fields
|
||||||
|
db_entity_data = entity_model_from_markdown(
|
||||||
|
file_path,
|
||||||
|
markdown,
|
||||||
|
project_id=self.repository.project_id,
|
||||||
|
)
|
||||||
|
db_entity_data.id = db_entity.id
|
||||||
|
db_entity_data.project_id = db_entity.project_id
|
||||||
|
db_entity_data.external_id = db_entity.external_id
|
||||||
|
db_entity_data.created_by = db_entity.created_by
|
||||||
|
|
||||||
# checksum value is None == not finished with sync
|
# checksum value is None == not finished with sync
|
||||||
db_entity.checksum = None
|
db_entity_data.checksum = None
|
||||||
|
|
||||||
# Set last_updated_by for cloud usage (preserve existing created_by)
|
# Set last_updated_by for cloud usage (preserve existing created_by)
|
||||||
user_id = self.get_user_id()
|
user_id = self.get_user_id()
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
db_entity.last_updated_by = user_id
|
db_entity_data.last_updated_by = user_id
|
||||||
|
else:
|
||||||
|
db_entity_data.last_updated_by = db_entity.last_updated_by
|
||||||
|
|
||||||
# update entity
|
# update entity
|
||||||
return await self.repository.update(
|
return await self.repository.update(
|
||||||
db_entity.id,
|
db_entity.id,
|
||||||
db_entity,
|
db_entity_data,
|
||||||
|
reload=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def upsert_entity_from_markdown(
|
async def upsert_entity_from_markdown(
|
||||||
@@ -905,26 +924,76 @@ class EntityService(BaseService[EntityModel]):
|
|||||||
markdown: EntityMarkdown,
|
markdown: EntityMarkdown,
|
||||||
*,
|
*,
|
||||||
is_new: bool,
|
is_new: bool,
|
||||||
|
checksum: Optional[str] = None,
|
||||||
) -> EntityModel:
|
) -> EntityModel:
|
||||||
"""Create/update entity and relations from parsed markdown."""
|
"""Create/update entity and relations from parsed markdown."""
|
||||||
if is_new:
|
# --- Base Entity Row ---
|
||||||
created = await self.create_entity_from_markdown(file_path, markdown)
|
# Trigger: writes rebuild the entity row before touching relation edges
|
||||||
else:
|
# Why: relations need a stable source entity ID, but not a fully hydrated graph
|
||||||
created = await self.update_entity_and_observations(file_path, markdown)
|
# Outcome: create/update the row with a lightweight return value
|
||||||
return await self.update_entity_relations(created.file_path, markdown)
|
with telemetry.scope(
|
||||||
|
"entity_service.upsert.base_entity",
|
||||||
|
domain="entity_service",
|
||||||
|
action="upsert",
|
||||||
|
phase="base_entity",
|
||||||
|
):
|
||||||
|
if is_new:
|
||||||
|
created = await self.create_entity_from_markdown(file_path, markdown)
|
||||||
|
else:
|
||||||
|
created = await self.update_entity_and_observations(file_path, markdown)
|
||||||
|
|
||||||
|
# --- Relation Edges ---
|
||||||
|
with telemetry.scope(
|
||||||
|
"entity_service.upsert.relations",
|
||||||
|
domain="entity_service",
|
||||||
|
action="upsert",
|
||||||
|
phase="relations",
|
||||||
|
):
|
||||||
|
await self.update_entity_relations(created, markdown)
|
||||||
|
|
||||||
|
# --- Final Entity State ---
|
||||||
|
# Trigger: create/update/edit already computed the final file checksum
|
||||||
|
# Why: fold the checksum write into the upsert flow so callers do one hydrated read
|
||||||
|
# Outcome: the write path returns the final entity state without an extra checksum step
|
||||||
|
if checksum is not None:
|
||||||
|
with telemetry.scope(
|
||||||
|
"entity_service.upsert.persist_checksum",
|
||||||
|
domain="entity_service",
|
||||||
|
action="upsert",
|
||||||
|
phase="persist_checksum",
|
||||||
|
):
|
||||||
|
updated = await self.repository.update(created.id, {"checksum": checksum})
|
||||||
|
if not updated: # pragma: no cover
|
||||||
|
raise ValueError(f"Failed to update entity checksum after upsert: {file_path}")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
with telemetry.scope(
|
||||||
|
"entity_service.upsert.hydrate_entity",
|
||||||
|
domain="entity_service",
|
||||||
|
action="upsert",
|
||||||
|
phase="hydrate_entity",
|
||||||
|
):
|
||||||
|
hydrated = await self.repository.get_by_file_path(created.file_path)
|
||||||
|
if not hydrated: # pragma: no cover
|
||||||
|
raise EntityNotFoundError(f"Entity not found after upsert: {created.file_path}")
|
||||||
|
return hydrated
|
||||||
|
|
||||||
async def update_entity_relations(
|
async def update_entity_relations(
|
||||||
self,
|
self,
|
||||||
path: str,
|
db_entity: EntityModel,
|
||||||
markdown: EntityMarkdown,
|
markdown: EntityMarkdown,
|
||||||
) -> EntityModel:
|
) -> None:
|
||||||
"""Update relations for entity"""
|
"""Update relations for entity"""
|
||||||
logger.debug(f"Updating relations for entity: {path}")
|
logger.debug(f"Updating relations for entity: {db_entity.file_path}")
|
||||||
|
|
||||||
db_entity = await self.repository.get_by_file_path(path)
|
|
||||||
|
|
||||||
# Clear existing relations first
|
# Clear existing relations first
|
||||||
await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
|
with telemetry.scope(
|
||||||
|
"entity_service.upsert.delete_relations",
|
||||||
|
domain="entity_service",
|
||||||
|
action="upsert",
|
||||||
|
phase="delete_relations",
|
||||||
|
):
|
||||||
|
await self.relation_repository.delete_outgoing_relations_from_entity(db_entity.id)
|
||||||
|
|
||||||
# Batch resolve all relation targets in parallel
|
# Batch resolve all relation targets in parallel
|
||||||
if markdown.relations:
|
if markdown.relations:
|
||||||
@@ -933,13 +1002,23 @@ class EntityService(BaseService[EntityModel]):
|
|||||||
# Create tasks for all relation lookups
|
# Create tasks for all relation lookups
|
||||||
# Use strict=True to disable fuzzy search - only exact matches should create resolved relations
|
# Use strict=True to disable fuzzy search - only exact matches should create resolved relations
|
||||||
# This ensures forward references (links to non-existent entities) remain unresolved (to_id=NULL)
|
# This ensures forward references (links to non-existent entities) remain unresolved (to_id=NULL)
|
||||||
lookup_tasks = [
|
with telemetry.scope(
|
||||||
self.link_resolver.resolve_link(rel.target, strict=True)
|
"entity_service.upsert.resolve_relation_targets",
|
||||||
for rel in markdown.relations
|
domain="entity_service",
|
||||||
]
|
action="upsert",
|
||||||
|
phase="resolve_relation_targets",
|
||||||
|
):
|
||||||
|
lookup_tasks = [
|
||||||
|
self.link_resolver.resolve_link(
|
||||||
|
rel.target,
|
||||||
|
strict=True,
|
||||||
|
load_relations=False,
|
||||||
|
)
|
||||||
|
for rel in markdown.relations
|
||||||
|
]
|
||||||
|
|
||||||
# Execute all lookups in parallel
|
# Execute all lookups in parallel
|
||||||
resolved_entities = await asyncio.gather(*lookup_tasks, return_exceptions=True)
|
resolved_entities = await asyncio.gather(*lookup_tasks, return_exceptions=True)
|
||||||
|
|
||||||
# Process results and create relation records
|
# Process results and create relation records
|
||||||
relations_to_add = []
|
relations_to_add = []
|
||||||
@@ -968,22 +1047,26 @@ class EntityService(BaseService[EntityModel]):
|
|||||||
|
|
||||||
# Batch insert all relations
|
# Batch insert all relations
|
||||||
if relations_to_add:
|
if relations_to_add:
|
||||||
try:
|
with telemetry.scope(
|
||||||
await self.relation_repository.add_all(relations_to_add)
|
"entity_service.upsert.insert_relations",
|
||||||
except IntegrityError:
|
domain="entity_service",
|
||||||
# Some relations might be duplicates - fall back to individual inserts
|
action="upsert",
|
||||||
logger.debug("Batch relation insert failed, trying individual inserts")
|
phase="insert_relations",
|
||||||
for relation in relations_to_add:
|
):
|
||||||
try:
|
try:
|
||||||
await self.relation_repository.add(relation)
|
await self.relation_repository.add_all(relations_to_add)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
# Unique constraint violation - relation already exists
|
# Some relations might be duplicates - fall back to individual inserts
|
||||||
logger.debug(
|
logger.debug("Batch relation insert failed, trying individual inserts")
|
||||||
f"Skipping duplicate relation {relation.relation_type} from {db_entity.permalink}"
|
for relation in relations_to_add:
|
||||||
)
|
try:
|
||||||
continue
|
await self.relation_repository.add(relation)
|
||||||
|
except IntegrityError:
|
||||||
return await self.repository.get_by_file_path(path)
|
# Unique constraint violation - relation already exists
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping duplicate relation {relation.relation_type} from {db_entity.permalink}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
async def edit_entity(
|
async def edit_entity(
|
||||||
self,
|
self,
|
||||||
@@ -1040,7 +1123,11 @@ class EntityService(BaseService[EntityModel]):
|
|||||||
action="edit",
|
action="edit",
|
||||||
phase="resolve_entity",
|
phase="resolve_entity",
|
||||||
):
|
):
|
||||||
entity = await self.link_resolver.resolve_link(identifier, strict=True)
|
entity = await self.link_resolver.resolve_link(
|
||||||
|
identifier,
|
||||||
|
strict=True,
|
||||||
|
load_relations=False,
|
||||||
|
)
|
||||||
if not entity:
|
if not entity:
|
||||||
raise EntityNotFoundError(f"Entity not found: {identifier}")
|
raise EntityNotFoundError(f"Entity not found: {identifier}")
|
||||||
|
|
||||||
@@ -1089,18 +1176,13 @@ class EntityService(BaseService[EntityModel]):
|
|||||||
phase="upsert_entity",
|
phase="upsert_entity",
|
||||||
):
|
):
|
||||||
entity = await self.upsert_entity_from_markdown(
|
entity = await self.upsert_entity_from_markdown(
|
||||||
file_path, entity_markdown, is_new=False
|
file_path,
|
||||||
|
entity_markdown,
|
||||||
|
is_new=False,
|
||||||
|
checksum=checksum,
|
||||||
)
|
)
|
||||||
|
|
||||||
with telemetry.scope(
|
|
||||||
"entity_service.edit.update_checksum",
|
|
||||||
domain="entity_service",
|
|
||||||
action="edit",
|
|
||||||
phase="update_checksum",
|
|
||||||
):
|
|
||||||
entity = await self.repository.update(entity.id, {"checksum": checksum})
|
|
||||||
if not entity: # pragma: no cover
|
if not entity: # pragma: no cover
|
||||||
raise ValueError(f"Failed to update entity checksum after edit: {file_path}")
|
raise ValueError(f"Failed to persist entity after edit: {file_path}")
|
||||||
|
|
||||||
return EntityWriteResult(
|
return EntityWriteResult(
|
||||||
entity=entity,
|
entity=entity,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class LinkResolver:
|
|||||||
use_search: bool = True,
|
use_search: bool = True,
|
||||||
strict: bool = False,
|
strict: bool = False,
|
||||||
source_path: Optional[str] = None,
|
source_path: Optional[str] = None,
|
||||||
|
load_relations: bool = True,
|
||||||
) -> Optional[Entity]:
|
) -> Optional[Entity]:
|
||||||
"""Resolve a markdown link to a permalink.
|
"""Resolve a markdown link to a permalink.
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ class LinkResolver:
|
|||||||
strict: If True, only exact matches are allowed (no fuzzy search fallback)
|
strict: If True, only exact matches are allowed (no fuzzy search fallback)
|
||||||
source_path: Optional path of the source file containing the link.
|
source_path: Optional path of the source file containing the link.
|
||||||
Used to prefer notes closer to the source (context-aware resolution).
|
Used to prefer notes closer to the source (context-aware resolution).
|
||||||
|
load_relations: When False, skip eager loading and return a lightweight entity row.
|
||||||
"""
|
"""
|
||||||
logger.trace(f"Resolving link: {link_text} (source: {source_path})")
|
logger.trace(f"Resolving link: {link_text} (source: {source_path})")
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ class LinkResolver:
|
|||||||
strict=strict,
|
strict=strict,
|
||||||
source_path=None,
|
source_path=None,
|
||||||
project_permalink=project.permalink,
|
project_permalink=project.permalink,
|
||||||
|
load_relations=load_relations,
|
||||||
)
|
)
|
||||||
|
|
||||||
current_project_permalink = await self._get_current_project_permalink()
|
current_project_permalink = await self._get_current_project_permalink()
|
||||||
@@ -109,6 +112,7 @@ class LinkResolver:
|
|||||||
strict=strict,
|
strict=strict,
|
||||||
source_path=source_path,
|
source_path=source_path,
|
||||||
project_permalink=current_project_permalink,
|
project_permalink=current_project_permalink,
|
||||||
|
load_relations=load_relations,
|
||||||
)
|
)
|
||||||
if resolved:
|
if resolved:
|
||||||
return resolved
|
return resolved
|
||||||
@@ -136,6 +140,7 @@ class LinkResolver:
|
|||||||
strict=strict,
|
strict=strict,
|
||||||
source_path=None,
|
source_path=None,
|
||||||
project_permalink=project.permalink,
|
project_permalink=project.permalink,
|
||||||
|
load_relations=load_relations,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
|
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
|
||||||
@@ -176,6 +181,7 @@ class LinkResolver:
|
|||||||
strict: bool,
|
strict: bool,
|
||||||
source_path: Optional[str],
|
source_path: Optional[str],
|
||||||
project_permalink: Optional[str],
|
project_permalink: Optional[str],
|
||||||
|
load_relations: bool,
|
||||||
) -> Optional[Entity]:
|
) -> Optional[Entity]:
|
||||||
"""Resolve a link within a specific project scope."""
|
"""Resolve a link within a specific project scope."""
|
||||||
clean_text = link_text
|
clean_text = link_text
|
||||||
@@ -223,12 +229,18 @@ class LinkResolver:
|
|||||||
# Try with .md extension
|
# Try with .md extension
|
||||||
if not relative_path.endswith(".md"):
|
if not relative_path.endswith(".md"):
|
||||||
relative_path_md = f"{relative_path}.md"
|
relative_path_md = f"{relative_path}.md"
|
||||||
entity = await entity_repository.get_by_file_path(relative_path_md)
|
entity = await entity_repository.get_by_file_path(
|
||||||
|
relative_path_md,
|
||||||
|
load_relations=load_relations,
|
||||||
|
)
|
||||||
if entity:
|
if entity:
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
# Try as-is (already has extension or is a permalink)
|
# Try as-is (already has extension or is a permalink)
|
||||||
entity = await entity_repository.get_by_file_path(relative_path)
|
entity = await entity_repository.get_by_file_path(
|
||||||
|
relative_path,
|
||||||
|
load_relations=load_relations,
|
||||||
|
)
|
||||||
if entity:
|
if entity:
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
@@ -242,12 +254,18 @@ class LinkResolver:
|
|||||||
|
|
||||||
# Check permalink match
|
# Check permalink match
|
||||||
for candidate_permalink in permalink_candidates:
|
for candidate_permalink in permalink_candidates:
|
||||||
permalink_entity = await entity_repository.get_by_permalink(candidate_permalink)
|
permalink_entity = await entity_repository.get_by_permalink(
|
||||||
|
candidate_permalink,
|
||||||
|
load_relations=load_relations,
|
||||||
|
)
|
||||||
if permalink_entity and permalink_entity.id not in [c.id for c in candidates]:
|
if permalink_entity and permalink_entity.id not in [c.id for c in candidates]:
|
||||||
candidates.append(permalink_entity)
|
candidates.append(permalink_entity)
|
||||||
|
|
||||||
# Check title matches
|
# Check title matches
|
||||||
title_entities = await entity_repository.get_by_title(clean_text)
|
title_entities = await entity_repository.get_by_title(
|
||||||
|
clean_text,
|
||||||
|
load_relations=load_relations,
|
||||||
|
)
|
||||||
for entity in title_entities:
|
for entity in title_entities:
|
||||||
# Avoid duplicates (permalink match might also be in title matches)
|
# Avoid duplicates (permalink match might also be in title matches)
|
||||||
if entity.id not in [c.id for c in candidates]:
|
if entity.id not in [c.id for c in candidates]:
|
||||||
@@ -263,13 +281,19 @@ class LinkResolver:
|
|||||||
# Standard resolution (no source context): permalink first, then title
|
# Standard resolution (no source context): permalink first, then title
|
||||||
# 1. Try exact permalink match first (most efficient)
|
# 1. Try exact permalink match first (most efficient)
|
||||||
for candidate_permalink in permalink_candidates:
|
for candidate_permalink in permalink_candidates:
|
||||||
entity = await entity_repository.get_by_permalink(candidate_permalink)
|
entity = await entity_repository.get_by_permalink(
|
||||||
|
candidate_permalink,
|
||||||
|
load_relations=load_relations,
|
||||||
|
)
|
||||||
if entity:
|
if entity:
|
||||||
logger.debug(f"Found exact permalink match: {entity.permalink}")
|
logger.debug(f"Found exact permalink match: {entity.permalink}")
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
# 2. Try exact title match
|
# 2. Try exact title match
|
||||||
found = await entity_repository.get_by_title(clean_text)
|
found = await entity_repository.get_by_title(
|
||||||
|
clean_text,
|
||||||
|
load_relations=load_relations,
|
||||||
|
)
|
||||||
if found:
|
if found:
|
||||||
# Return first match (shortest path) if no source context
|
# Return first match (shortest path) if no source context
|
||||||
entity = found[0]
|
entity = found[0]
|
||||||
@@ -277,7 +301,10 @@ class LinkResolver:
|
|||||||
return entity
|
return entity
|
||||||
|
|
||||||
# 3. Try file path
|
# 3. Try file path
|
||||||
found_path = await entity_repository.get_by_file_path(clean_text)
|
found_path = await entity_repository.get_by_file_path(
|
||||||
|
clean_text,
|
||||||
|
load_relations=load_relations,
|
||||||
|
)
|
||||||
if found_path:
|
if found_path:
|
||||||
logger.debug(f"Found entity with path: {found_path.file_path}")
|
logger.debug(f"Found entity with path: {found_path.file_path}")
|
||||||
return found_path
|
return found_path
|
||||||
@@ -285,7 +312,10 @@ class LinkResolver:
|
|||||||
# 4. Try file path with .md extension if not already present
|
# 4. Try file path with .md extension if not already present
|
||||||
if not clean_text.endswith(".md") and "/" in clean_text:
|
if not clean_text.endswith(".md") and "/" in clean_text:
|
||||||
file_path_with_md = f"{clean_text}.md"
|
file_path_with_md = f"{clean_text}.md"
|
||||||
found_path_md = await entity_repository.get_by_file_path(file_path_with_md)
|
found_path_md = await entity_repository.get_by_file_path(
|
||||||
|
file_path_with_md,
|
||||||
|
load_relations=load_relations,
|
||||||
|
)
|
||||||
if found_path_md:
|
if found_path_md:
|
||||||
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
|
logger.debug(f"Found entity with path (with .md): {found_path_md.file_path}")
|
||||||
return found_path_md
|
return found_path_md
|
||||||
@@ -309,7 +339,10 @@ class LinkResolver:
|
|||||||
f"Selected best match from {len(results)} results: {best_match.permalink}"
|
f"Selected best match from {len(results)} results: {best_match.permalink}"
|
||||||
)
|
)
|
||||||
if best_match.permalink:
|
if best_match.permalink:
|
||||||
return await entity_repository.get_by_permalink(best_match.permalink)
|
return await entity_repository.get_by_permalink(
|
||||||
|
best_match.permalink,
|
||||||
|
load_relations=load_relations,
|
||||||
|
)
|
||||||
|
|
||||||
# if we couldn't find anything then return None
|
# if we couldn't find anything then return None
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ async def test_create_entity_emits_expected_phase_spans(entity_service, monkeypa
|
|||||||
"file_service.write",
|
"file_service.write",
|
||||||
"entity_service.create.parse_markdown",
|
"entity_service.create.parse_markdown",
|
||||||
"entity_service.create.upsert_entity",
|
"entity_service.create.upsert_entity",
|
||||||
"entity_service.create.update_checksum",
|
"entity_service.upsert.base_entity",
|
||||||
|
"entity_service.upsert.relations",
|
||||||
|
"entity_service.upsert.persist_checksum",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,7 +95,9 @@ async def test_edit_entity_emits_expected_phase_spans(entity_service, monkeypatc
|
|||||||
"file_service.write",
|
"file_service.write",
|
||||||
"entity_service.edit.parse_markdown",
|
"entity_service.edit.parse_markdown",
|
||||||
"entity_service.edit.upsert_entity",
|
"entity_service.edit.upsert_entity",
|
||||||
"entity_service.edit.update_checksum",
|
"entity_service.upsert.base_entity",
|
||||||
|
"entity_service.upsert.relations",
|
||||||
|
"entity_service.upsert.persist_checksum",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,6 +128,9 @@ async def test_reindex_entity_emits_expected_phase_spans(entity_service, monkeyp
|
|||||||
"file_service.read_content",
|
"file_service.read_content",
|
||||||
"entity_service.reindex.parse_markdown",
|
"entity_service.reindex.parse_markdown",
|
||||||
"entity_service.reindex.upsert_entity",
|
"entity_service.reindex.upsert_entity",
|
||||||
|
"entity_service.upsert.base_entity",
|
||||||
|
"entity_service.upsert.relations",
|
||||||
|
"entity_service.upsert.hydrate_entity",
|
||||||
"entity_service.reindex.update_checksum",
|
"entity_service.reindex.update_checksum",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user