basic-memory 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (73) hide show
  1. basic_memory/__init__.py +3 -0
  2. basic_memory/api/__init__.py +4 -0
  3. basic_memory/api/app.py +42 -0
  4. basic_memory/api/routers/__init__.py +8 -0
  5. basic_memory/api/routers/knowledge_router.py +168 -0
  6. basic_memory/api/routers/memory_router.py +123 -0
  7. basic_memory/api/routers/resource_router.py +34 -0
  8. basic_memory/api/routers/search_router.py +34 -0
  9. basic_memory/cli/__init__.py +1 -0
  10. basic_memory/cli/app.py +4 -0
  11. basic_memory/cli/commands/__init__.py +9 -0
  12. basic_memory/cli/commands/init.py +38 -0
  13. basic_memory/cli/commands/status.py +152 -0
  14. basic_memory/cli/commands/sync.py +254 -0
  15. basic_memory/cli/main.py +48 -0
  16. basic_memory/config.py +53 -0
  17. basic_memory/db.py +135 -0
  18. basic_memory/deps.py +182 -0
  19. basic_memory/file_utils.py +248 -0
  20. basic_memory/markdown/__init__.py +19 -0
  21. basic_memory/markdown/entity_parser.py +137 -0
  22. basic_memory/markdown/markdown_processor.py +153 -0
  23. basic_memory/markdown/plugins.py +236 -0
  24. basic_memory/markdown/schemas.py +73 -0
  25. basic_memory/markdown/utils.py +144 -0
  26. basic_memory/mcp/__init__.py +1 -0
  27. basic_memory/mcp/async_client.py +10 -0
  28. basic_memory/mcp/main.py +21 -0
  29. basic_memory/mcp/server.py +39 -0
  30. basic_memory/mcp/tools/__init__.py +34 -0
  31. basic_memory/mcp/tools/ai_edit.py +84 -0
  32. basic_memory/mcp/tools/knowledge.py +56 -0
  33. basic_memory/mcp/tools/memory.py +142 -0
  34. basic_memory/mcp/tools/notes.py +122 -0
  35. basic_memory/mcp/tools/search.py +28 -0
  36. basic_memory/mcp/tools/utils.py +154 -0
  37. basic_memory/models/__init__.py +12 -0
  38. basic_memory/models/base.py +9 -0
  39. basic_memory/models/knowledge.py +204 -0
  40. basic_memory/models/search.py +34 -0
  41. basic_memory/repository/__init__.py +7 -0
  42. basic_memory/repository/entity_repository.py +156 -0
  43. basic_memory/repository/observation_repository.py +40 -0
  44. basic_memory/repository/relation_repository.py +78 -0
  45. basic_memory/repository/repository.py +303 -0
  46. basic_memory/repository/search_repository.py +259 -0
  47. basic_memory/schemas/__init__.py +73 -0
  48. basic_memory/schemas/base.py +216 -0
  49. basic_memory/schemas/delete.py +38 -0
  50. basic_memory/schemas/discovery.py +25 -0
  51. basic_memory/schemas/memory.py +111 -0
  52. basic_memory/schemas/request.py +77 -0
  53. basic_memory/schemas/response.py +220 -0
  54. basic_memory/schemas/search.py +117 -0
  55. basic_memory/services/__init__.py +11 -0
  56. basic_memory/services/context_service.py +274 -0
  57. basic_memory/services/entity_service.py +281 -0
  58. basic_memory/services/exceptions.py +15 -0
  59. basic_memory/services/file_service.py +213 -0
  60. basic_memory/services/link_resolver.py +126 -0
  61. basic_memory/services/search_service.py +218 -0
  62. basic_memory/services/service.py +36 -0
  63. basic_memory/sync/__init__.py +5 -0
  64. basic_memory/sync/file_change_scanner.py +162 -0
  65. basic_memory/sync/sync_service.py +140 -0
  66. basic_memory/sync/utils.py +66 -0
  67. basic_memory/sync/watch_service.py +197 -0
  68. basic_memory/utils.py +78 -0
  69. basic_memory-0.0.0.dist-info/METADATA +71 -0
  70. basic_memory-0.0.0.dist-info/RECORD +73 -0
  71. basic_memory-0.0.0.dist-info/WHEEL +4 -0
  72. basic_memory-0.0.0.dist-info/entry_points.txt +2 -0
  73. basic_memory-0.0.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,204 @@
1
+ """Knowledge graph models."""
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from typing import Optional
6
+
7
+ from sqlalchemy import (
8
+ Integer,
9
+ String,
10
+ Text,
11
+ ForeignKey,
12
+ UniqueConstraint,
13
+ DateTime,
14
+ Index,
15
+ JSON,
16
+ )
17
+ from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
18
+
19
+ from basic_memory.models.base import Base
20
+ from enum import Enum
21
+
22
+ from basic_memory.utils import generate_permalink
23
+
24
+
25
+ class Entity(Base):
26
+ """
27
+ Core entity in the knowledge graph.
28
+
29
+ Entities represent semantic nodes maintained by the AI layer. Each entity:
30
+ - Has a unique numeric ID (database-generated)
31
+ - Maps to a file on disk
32
+ - Maintains a checksum for change detection
33
+ - Tracks both source file and semantic properties
34
+ """
35
+
36
+ __tablename__ = "entity"
37
+ __table_args__ = (
38
+ UniqueConstraint("permalink", name="uix_entity_permalink"), # Make permalink unique
39
+ Index("ix_entity_type", "entity_type"),
40
+ Index("ix_entity_title", "title"),
41
+ Index("ix_entity_created_at", "created_at"), # For timeline queries
42
+ Index("ix_entity_updated_at", "updated_at"), # For timeline queries
43
+ )
44
+
45
+ # Core identity
46
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
47
+ title: Mapped[str] = mapped_column(String)
48
+ entity_type: Mapped[str] = mapped_column(String)
49
+ entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
50
+ content_type: Mapped[str] = mapped_column(String)
51
+
52
+ # Normalized path for URIs
53
+ permalink: Mapped[str] = mapped_column(String, unique=True, index=True)
54
+ # Actual filesystem relative path
55
+ file_path: Mapped[str] = mapped_column(String, unique=True, index=True)
56
+ # checksum of file
57
+ checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
58
+
59
+ # Metadata and tracking
60
+ created_at: Mapped[datetime] = mapped_column(DateTime)
61
+ updated_at: Mapped[datetime] = mapped_column(DateTime)
62
+
63
+ # Relationships
64
+ observations = relationship(
65
+ "Observation", back_populates="entity", cascade="all, delete-orphan"
66
+ )
67
+ outgoing_relations = relationship(
68
+ "Relation",
69
+ back_populates="from_entity",
70
+ foreign_keys="[Relation.from_id]",
71
+ cascade="all, delete-orphan",
72
+ )
73
+ incoming_relations = relationship(
74
+ "Relation",
75
+ back_populates="to_entity",
76
+ foreign_keys="[Relation.to_id]",
77
+ cascade="all, delete-orphan",
78
+ )
79
+
80
+ @property
81
+ def relations(self):
82
+ return self.incoming_relations + self.outgoing_relations
83
+
84
+ @validates("permalink")
85
+ def validate_permalink(self, key, value):
86
+ """Validate permalink format.
87
+
88
+ Requirements:
89
+ 1. Must be valid URI path component
90
+ 2. Only lowercase letters, numbers, and hyphens (no underscores)
91
+ 3. Path segments separated by forward slashes
92
+ 4. No leading/trailing hyphens in segments
93
+ """
94
+ if not value:
95
+ raise ValueError("Permalink must not be None")
96
+
97
+ if not re.match(r"^[a-z0-9][a-z0-9\-/]*[a-z0-9]$", value):
98
+ raise ValueError(
99
+ f"Invalid permalink format: {value}. "
100
+ "Use only lowercase letters, numbers, and hyphens."
101
+ )
102
+ return value
103
+
104
+ def __repr__(self) -> str:
105
+ return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}'"
106
+
107
+
108
+ class ObservationCategory(str, Enum):
109
+ TECH = "tech"
110
+ DESIGN = "design"
111
+ FEATURE = "feature"
112
+ NOTE = "note"
113
+ ISSUE = "issue"
114
+ TODO = "todo"
115
+
116
+
117
+ class Observation(Base):
118
+ """
119
+ An observation about an entity.
120
+
121
+ Observations are atomic facts or notes about an entity.
122
+ """
123
+
124
+ __tablename__ = "observation"
125
+ __table_args__ = (
126
+ Index("ix_observation_entity_id", "entity_id"), # Add FK index
127
+ Index("ix_observation_category", "category"), # Add category index
128
+ )
129
+
130
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
131
+ entity_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
132
+ content: Mapped[str] = mapped_column(Text)
133
+ category: Mapped[str] = mapped_column(
134
+ String,
135
+ nullable=False,
136
+ default=ObservationCategory.NOTE.value,
137
+ server_default=ObservationCategory.NOTE.value,
138
+ )
139
+ context: Mapped[str] = mapped_column(Text, nullable=True)
140
+ tags: Mapped[Optional[list[str]]] = mapped_column(
141
+ JSON, nullable=True, default=list, server_default="[]"
142
+ )
143
+
144
+ # Relationships
145
+ entity = relationship("Entity", back_populates="observations")
146
+
147
+ @property
148
+ def permalink(self) -> str:
149
+ """
150
+ Create synthetic permalink for the observation
151
+ We can construct these because observations are always
152
+ defined in and owned by a single entity
153
+ """
154
+ return generate_permalink(
155
+ f"{self.entity.permalink}/observations/{self.category}/{self.content}"
156
+ )
157
+
158
+ def __repr__(self) -> str:
159
+ return f"Observation(id={self.id}, entity_id={self.entity_id}, content='{self.content}')"
160
+
161
+
162
+ class Relation(Base):
163
+ """
164
+ A directed relation between two entities.
165
+ """
166
+
167
+ __tablename__ = "relation"
168
+ __table_args__ = (
169
+ UniqueConstraint("from_id", "to_id", "relation_type", name="uix_relation"),
170
+ Index("ix_relation_type", "relation_type"),
171
+ Index("ix_relation_from_id", "from_id"), # Add FK indexes
172
+ Index("ix_relation_to_id", "to_id"),
173
+ )
174
+
175
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
176
+ from_id: Mapped[int] = mapped_column(Integer, ForeignKey("entity.id", ondelete="CASCADE"))
177
+ to_id: Mapped[int] = mapped_column(
178
+ Integer, ForeignKey("entity.id", ondelete="CASCADE"), nullable=True
179
+ )
180
+ to_name: Mapped[str] = mapped_column(String)
181
+ relation_type: Mapped[str] = mapped_column(String)
182
+ context: Mapped[str] = mapped_column(Text, nullable=True)
183
+
184
+ # Relationships
185
+ from_entity = relationship(
186
+ "Entity", foreign_keys=[from_id], back_populates="outgoing_relations"
187
+ )
188
+ to_entity = relationship("Entity", foreign_keys=[to_id], back_populates="incoming_relations")
189
+
190
+ @property
191
+ def permalink(self) -> str:
192
+ """Create relation permalink showing the semantic connection:
193
+ source/relation_type/target
194
+ e.g., "specs/search/implements/features/search-ui"
195
+ """
196
+
197
+ return generate_permalink(
198
+ f"{self.from_entity.permalink}/{self.relation_type}/{self.to_entity.permalink}"
199
+ if self.to_entity
200
+ else f"{self.from_entity.permalink}/{self.relation_type}/{self.to_name}"
201
+ )
202
+
203
+ def __repr__(self) -> str:
204
+ return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, to_name={self.to_name}, type='{self.relation_type}')"
@@ -0,0 +1,34 @@
1
+ """Search models and tables."""
2
+
3
+ from sqlalchemy import DDL
4
+
5
+ # Define FTS5 virtual table creation
6
+ CREATE_SEARCH_INDEX = DDL("""
7
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
8
+ -- Core entity fields
9
+ id UNINDEXED, -- Row ID
10
+ title, -- Title for searching
11
+ content, -- Main searchable content
12
+ permalink, -- Stable identifier (now indexed for path search)
13
+ file_path UNINDEXED, -- Physical location
14
+ type UNINDEXED, -- entity/relation/observation
15
+
16
+ -- Relation fields
17
+ from_id UNINDEXED, -- Source entity
18
+ to_id UNINDEXED, -- Target entity
19
+ relation_type UNINDEXED, -- Type of relation
20
+
21
+ -- Observation fields
22
+ entity_id UNINDEXED, -- Parent entity
23
+ category UNINDEXED, -- Observation category
24
+
25
+ -- Common fields
26
+ metadata UNINDEXED, -- JSON metadata
27
+ created_at UNINDEXED, -- Creation timestamp
28
+ updated_at UNINDEXED, -- Last update
29
+
30
+ -- Configuration
31
+ tokenize='unicode61 tokenchars 0x2F', -- Hex code for /
32
+ prefix='1,2,3,4' -- Support longer prefixes for paths
33
+ );
34
+ """)
@@ -0,0 +1,7 @@
1
+ from .entity_repository import EntityRepository
2
+ from .observation_repository import ObservationRepository
3
+ from .relation_repository import RelationRepository
4
+
5
+ __all__ = ["EntityRepository", "ObservationRepository", "RelationRepository", ]
6
+
7
+
@@ -0,0 +1,156 @@
1
+ """Repository for managing entities in the knowledge graph."""
2
+
3
+ from typing import List, Optional, Sequence
4
+
5
+ from sqlalchemy import select, or_, asc
6
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
7
+ from sqlalchemy.orm import selectinload
8
+ from sqlalchemy.orm.interfaces import LoaderOption
9
+
10
+ from basic_memory.models.knowledge import Entity, Observation, Relation
11
+ from basic_memory.repository.repository import Repository
12
+
13
+
14
+ class EntityRepository(Repository[Entity]):
15
+ """Repository for Entity model."""
16
+
17
+ def __init__(self, session_maker: async_sessionmaker[AsyncSession]):
18
+ """Initialize with session maker."""
19
+ super().__init__(session_maker, Entity)
20
+
21
+ async def get_by_permalink(self, permalink: str) -> Optional[Entity]:
22
+ """Get entity by permalink."""
23
+ query = self.select().where(Entity.permalink == permalink).options(*self.get_load_options())
24
+ return await self.find_one(query)
25
+
26
+ async def get_by_title(self, title: str) -> Optional[Entity]:
27
+ """Get entity by title."""
28
+ query = self.select().where(Entity.title == title).options(*self.get_load_options())
29
+ return await self.find_one(query)
30
+
31
+ async def get_by_file_path(self, file_path: str) -> Optional[Entity]:
32
+ """Get entity by file_path."""
33
+ query = self.select().where(Entity.file_path == file_path).options(*self.get_load_options())
34
+ return await self.find_one(query)
35
+
36
+ async def list_entities(
37
+ self,
38
+ entity_type: Optional[str] = None,
39
+ sort_by: Optional[str] = "updated_at",
40
+ include_related: bool = False,
41
+ ) -> Sequence[Entity]:
42
+ """List all entities, optionally filtered by type and sorted."""
43
+ query = self.select()
44
+
45
+ # Always load base relations
46
+ query = query.options(*self.get_load_options())
47
+
48
+ # Apply filters
49
+ if entity_type:
50
+ # When include_related is True, get both:
51
+ # 1. Entities of the requested type
52
+ # 2. Entities that have relations with entities of the requested type
53
+ if include_related:
54
+ query = query.where(
55
+ or_(
56
+ Entity.entity_type == entity_type,
57
+ Entity.outgoing_relations.any(
58
+ Relation.to_entity.has(entity_type=entity_type)
59
+ ),
60
+ Entity.incoming_relations.any(
61
+ Relation.from_entity.has(entity_type=entity_type)
62
+ ),
63
+ )
64
+ )
65
+ else:
66
+ query = query.where(Entity.entity_type == entity_type)
67
+
68
+ # Apply sorting
69
+ if sort_by:
70
+ sort_field = getattr(Entity, sort_by, Entity.updated_at)
71
+ query = query.order_by(asc(sort_field))
72
+
73
+ result = await self.execute_query(query)
74
+ return list(result.scalars().all())
75
+
76
+ async def get_entity_types(self) -> List[str]:
77
+ """Get list of distinct entity types."""
78
+ query = select(Entity.entity_type).distinct()
79
+
80
+ result = await self.execute_query(query, use_query_options=False)
81
+ return list(result.scalars().all())
82
+
83
+ async def search(self, query_str: str) -> List[Entity]:
84
+ """
85
+ Search for entities.
86
+
87
+ Searches across:
88
+ - Entity names
89
+ - Entity types
90
+ - Entity descriptions
91
+ - Associated Observations content
92
+ """
93
+ search_term = f"%{query_str}%"
94
+ query = (
95
+ self.select()
96
+ .where(
97
+ or_(
98
+ Entity.title.ilike(search_term),
99
+ Entity.entity_type.ilike(search_term),
100
+ Entity.summary.ilike(search_term),
101
+ Entity.observations.any(Observation.content.ilike(search_term)),
102
+ )
103
+ )
104
+ .options(*self.get_load_options())
105
+ )
106
+ result = await self.execute_query(query)
107
+ return list(result.scalars().all())
108
+
109
+ async def delete_entities_by_doc_id(self, doc_id: int) -> bool:
110
+ """Delete all entities associated with a document."""
111
+ return await self.delete_by_fields(doc_id=doc_id)
112
+
113
+ async def delete_by_file_path(self, file_path: str) -> bool:
114
+ """Delete entity with the provided file_path."""
115
+ return await self.delete_by_fields(file_path=file_path)
116
+
117
+ def get_load_options(self) -> List[LoaderOption]:
118
+ return [
119
+ selectinload(Entity.observations).selectinload(Observation.entity),
120
+ # Load from_relations and both entities for each relation
121
+ selectinload(Entity.outgoing_relations).selectinload(Relation.from_entity),
122
+ selectinload(Entity.outgoing_relations).selectinload(Relation.to_entity),
123
+ # Load to_relations and both entities for each relation
124
+ selectinload(Entity.incoming_relations).selectinload(Relation.from_entity),
125
+ selectinload(Entity.incoming_relations).selectinload(Relation.to_entity),
126
+ ]
127
+
128
+ async def find_by_permalinks(self, permalinks: List[str]) -> Sequence[Entity]:
129
+ """Find multiple entities by their permalink."""
130
+
131
+ # Handle empty input explicitly
132
+ if not permalinks:
133
+ return []
134
+
135
+ # Use existing select pattern
136
+ query = (
137
+ self.select().options(*self.get_load_options()).where(Entity.permalink.in_(permalinks))
138
+ )
139
+
140
+ result = await self.execute_query(query)
141
+ return list(result.scalars().all())
142
+
143
+ async def delete_by_permalinks(self, permalinks: List[str]) -> int:
144
+ """Delete multiple entities by permalink."""
145
+
146
+ # Handle empty input explicitly
147
+ if not permalinks:
148
+ return 0
149
+
150
+ # Find matching entities
151
+ entities = await self.find_by_permalinks(permalinks)
152
+ if not entities:
153
+ return 0
154
+
155
+ # Use existing delete_by_ids
156
+ return await self.delete_by_ids([entity.id for entity in entities])
@@ -0,0 +1,40 @@
1
+ """Repository for managing Observation objects."""
2
+
3
+ from typing import Sequence
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.ext.asyncio import async_sessionmaker
7
+
8
+ from basic_memory.models import Observation
9
+ from basic_memory.repository.repository import Repository
10
+
11
+
12
+ class ObservationRepository(Repository[Observation]):
13
+ """Repository for Observation model with memory-specific operations."""
14
+
15
+ def __init__(self, session_maker: async_sessionmaker):
16
+ super().__init__(session_maker, Observation)
17
+
18
+ async def find_by_entity(self, entity_id: int) -> Sequence[Observation]:
19
+ """Find all observations for a specific entity."""
20
+ query = select(Observation).filter(Observation.entity_id == entity_id)
21
+ result = await self.execute_query(query)
22
+ return result.scalars().all()
23
+
24
+ async def find_by_context(self, context: str) -> Sequence[Observation]:
25
+ """Find observations with a specific context."""
26
+ query = select(Observation).filter(Observation.context == context)
27
+ result = await self.execute_query(query)
28
+ return result.scalars().all()
29
+
30
+ async def find_by_category(self, category: str) -> Sequence[Observation]:
31
+ """Find observations with a specific context."""
32
+ query = select(Observation).filter(Observation.category == category)
33
+ result = await self.execute_query(query)
34
+ return result.scalars().all()
35
+
36
+ async def observation_categories(self) -> Sequence[str]:
37
+ """Return a list of all observation categories."""
38
+ query = select(Observation.category).distinct()
39
+ result = await self.execute_query(query, use_query_options=False)
40
+ return result.scalars().all()
@@ -0,0 +1,78 @@
1
+ """Repository for managing Relation objects."""
2
+
3
+ from sqlalchemy import and_, delete
4
+ from typing import Sequence, List, Optional
5
+
6
+ from sqlalchemy import select
7
+ from sqlalchemy.ext.asyncio import async_sessionmaker
8
+ from sqlalchemy.orm import selectinload, aliased
9
+ from sqlalchemy.orm.interfaces import LoaderOption
10
+
11
+ from basic_memory import db
12
+ from basic_memory.models import Relation, Entity
13
+ from basic_memory.repository.repository import Repository
14
+
15
+
16
+ class RelationRepository(Repository[Relation]):
17
+ """Repository for Relation model with memory-specific operations."""
18
+
19
+ def __init__(self, session_maker: async_sessionmaker):
20
+ super().__init__(session_maker, Relation)
21
+
22
+ async def find_relation(
23
+ self, from_permalink: str, to_permalink: str, relation_type: str
24
+ ) -> Optional[Relation]:
25
+ """Find a relation by its from and to path IDs."""
26
+ from_entity = aliased(Entity)
27
+ to_entity = aliased(Entity)
28
+
29
+ query = (
30
+ select(Relation)
31
+ .join(from_entity, Relation.from_id == from_entity.id)
32
+ .join(to_entity, Relation.to_id == to_entity.id)
33
+ .where(
34
+ and_(
35
+ from_entity.permalink == from_permalink,
36
+ to_entity.permalink == to_permalink,
37
+ Relation.relation_type == relation_type,
38
+ )
39
+ )
40
+ )
41
+ return await self.find_one(query)
42
+
43
+ async def find_by_entity(self, from_entity_id: int) -> Sequence[Relation]:
44
+ """Find all relations from a specific entity."""
45
+ query = select(Relation).filter(Relation.from_id == from_entity_id)
46
+ result = await self.execute_query(query)
47
+ return result.scalars().all()
48
+
49
+ async def find_by_entities(self, from_id: int, to_id: int) -> Sequence[Relation]:
50
+ """Find all relations between two entities."""
51
+ query = select(Relation).where((Relation.from_id == from_id) & (Relation.to_id == to_id))
52
+ result = await self.execute_query(query)
53
+ return result.scalars().all()
54
+
55
+ async def find_by_type(self, relation_type: str) -> Sequence[Relation]:
56
+ """Find all relations of a specific type."""
57
+ query = select(Relation).filter(Relation.relation_type == relation_type)
58
+ result = await self.execute_query(query)
59
+ return result.scalars().all()
60
+
61
+ async def delete_outgoing_relations_from_entity(self, entity_id: int) -> None:
62
+ """Delete outgoing relations for an entity.
63
+
64
+ Only deletes relations where this entity is the source (from_id),
65
+ as these are the ones owned by this entity's markdown file.
66
+ """
67
+ async with db.scoped_session(self.session_maker) as session:
68
+ await session.execute(delete(Relation).where(Relation.from_id == entity_id))
69
+
70
+ async def find_unresolved_relations(self) -> Sequence[Relation]:
71
+ """Find all unresolved relations, where to_id is null."""
72
+ query = select(Relation).filter(Relation.to_id.is_(None))
73
+ result = await self.execute_query(query)
74
+ return result.scalars().all()
75
+
76
+
77
+ def get_load_options(self) -> List[LoaderOption]:
78
+ return [selectinload(Relation.from_entity), selectinload(Relation.to_entity)]