basic-memory 0.17.1__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.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
basic_memory/db.py ADDED
@@ -0,0 +1,394 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ from contextlib import asynccontextmanager
5
+ from enum import Enum, auto
6
+ from pathlib import Path
7
+ from typing import AsyncGenerator, Optional
8
+
9
+ from basic_memory.config import BasicMemoryConfig, ConfigManager, DatabaseBackend
10
+ from alembic import command
11
+ from alembic.config import Config
12
+
13
+ from loguru import logger
14
+ from sqlalchemy import text, event
15
+ from sqlalchemy.ext.asyncio import (
16
+ create_async_engine,
17
+ async_sessionmaker,
18
+ AsyncSession,
19
+ AsyncEngine,
20
+ async_scoped_session,
21
+ )
22
+ from sqlalchemy.pool import NullPool
23
+
24
+ from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
25
+ from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
26
+
27
+ # -----------------------------------------------------------------------------
28
+ # Windows event loop policy
29
+ # -----------------------------------------------------------------------------
30
+ # On Windows, the default ProactorEventLoop has known rough edges with aiosqlite
31
+ # during shutdown/teardown (threads posting results to a loop that's closing),
32
+ # which can manifest as:
33
+ # - "RuntimeError: Event loop is closed"
34
+ # - "IndexError: pop from an empty deque"
35
+ #
36
+ # The SelectorEventLoop doesn't support subprocess operations, so code that uses
37
+ # asyncio.create_subprocess_shell() (like sync_service._quick_count_files) must
38
+ # detect Windows and use fallback implementations.
39
+ if sys.platform == "win32": # pragma: no cover
40
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
41
+
42
+ # Module level state
43
+ _engine: Optional[AsyncEngine] = None
44
+ _session_maker: Optional[async_sessionmaker[AsyncSession]] = None
45
+
46
+
47
+ class DatabaseType(Enum):
48
+ """Types of supported databases."""
49
+
50
+ MEMORY = auto()
51
+ FILESYSTEM = auto()
52
+ POSTGRES = auto()
53
+
54
+ @classmethod
55
+ def get_db_url(
56
+ cls, db_path: Path, db_type: "DatabaseType", config: Optional[BasicMemoryConfig] = None
57
+ ) -> str:
58
+ """Get SQLAlchemy URL for database path.
59
+
60
+ Args:
61
+ db_path: Path to SQLite database file (ignored for Postgres)
62
+ db_type: Type of database (MEMORY, FILESYSTEM, or POSTGRES)
63
+ config: Optional config to check for database backend and URL
64
+
65
+ Returns:
66
+ SQLAlchemy connection URL
67
+ """
68
+ # Load config if not provided
69
+ if config is None:
70
+ config = ConfigManager().config
71
+
72
+ # Handle explicit Postgres type
73
+ if db_type == cls.POSTGRES:
74
+ if not config.database_url:
75
+ raise ValueError("DATABASE_URL must be set when using Postgres backend")
76
+ logger.info(f"Using Postgres database: {config.database_url}")
77
+ return config.database_url
78
+
79
+ # Check if Postgres backend is configured (for backward compatibility)
80
+ if config.database_backend == DatabaseBackend.POSTGRES:
81
+ if not config.database_url:
82
+ raise ValueError("DATABASE_URL must be set when using Postgres backend")
83
+ logger.info(f"Using Postgres database: {config.database_url}")
84
+ return config.database_url
85
+
86
+ # SQLite databases
87
+ if db_type == cls.MEMORY:
88
+ logger.info("Using in-memory SQLite database")
89
+ return "sqlite+aiosqlite://"
90
+
91
+ return f"sqlite+aiosqlite:///{db_path}" # pragma: no cover
92
+
93
+
94
+ def get_scoped_session_factory(
95
+ session_maker: async_sessionmaker[AsyncSession],
96
+ ) -> async_scoped_session:
97
+ """Create a scoped session factory scoped to current task."""
98
+ return async_scoped_session(session_maker, scopefunc=asyncio.current_task)
99
+
100
+
101
+ @asynccontextmanager
102
+ async def scoped_session(
103
+ session_maker: async_sessionmaker[AsyncSession],
104
+ ) -> AsyncGenerator[AsyncSession, None]:
105
+ """
106
+ Get a scoped session with proper lifecycle management.
107
+
108
+ Args:
109
+ session_maker: Session maker to create scoped sessions from
110
+ """
111
+ factory = get_scoped_session_factory(session_maker)
112
+ session = factory()
113
+ try:
114
+ # Only enable foreign keys for SQLite (Postgres has them enabled by default)
115
+ # Detect database type from session's bind (engine) dialect
116
+ engine = session.get_bind()
117
+ dialect_name = engine.dialect.name
118
+
119
+ if dialect_name == "sqlite":
120
+ await session.execute(text("PRAGMA foreign_keys=ON"))
121
+
122
+ yield session
123
+ await session.commit()
124
+ except Exception:
125
+ await session.rollback()
126
+ raise
127
+ finally:
128
+ await session.close()
129
+ await factory.remove()
130
+
131
+
132
+ def _configure_sqlite_connection(dbapi_conn, enable_wal: bool = True) -> None:
133
+ """Configure SQLite connection with WAL mode and optimizations.
134
+
135
+ Args:
136
+ dbapi_conn: Database API connection object
137
+ enable_wal: Whether to enable WAL mode (should be False for in-memory databases)
138
+ """
139
+ cursor = dbapi_conn.cursor()
140
+ try:
141
+ # Enable WAL mode for better concurrency (not supported for in-memory databases)
142
+ if enable_wal:
143
+ cursor.execute("PRAGMA journal_mode=WAL")
144
+ # Set busy timeout to handle locked databases
145
+ cursor.execute("PRAGMA busy_timeout=10000") # 10 seconds
146
+ # Optimize for performance
147
+ cursor.execute("PRAGMA synchronous=NORMAL")
148
+ cursor.execute("PRAGMA cache_size=-64000") # 64MB cache
149
+ cursor.execute("PRAGMA temp_store=MEMORY")
150
+ # Windows-specific optimizations
151
+ if os.name == "nt":
152
+ cursor.execute("PRAGMA locking_mode=NORMAL") # Ensure normal locking on Windows
153
+ except Exception as e:
154
+ # Log but don't fail - some PRAGMAs may not be supported
155
+ logger.warning(f"Failed to configure SQLite connection: {e}")
156
+ finally:
157
+ cursor.close()
158
+
159
+
160
+ def _create_sqlite_engine(db_url: str, db_type: DatabaseType) -> AsyncEngine:
161
+ """Create SQLite async engine with appropriate configuration.
162
+
163
+ Args:
164
+ db_url: SQLite connection URL
165
+ db_type: Database type (MEMORY or FILESYSTEM)
166
+
167
+ Returns:
168
+ Configured async engine for SQLite
169
+ """
170
+ # Configure connection args with Windows-specific settings
171
+ connect_args: dict[str, bool | float | None] = {"check_same_thread": False}
172
+
173
+ # Add Windows-specific parameters to improve reliability
174
+ if os.name == "nt": # Windows
175
+ connect_args.update(
176
+ {
177
+ "timeout": 30.0, # Increase timeout to 30 seconds for Windows
178
+ "isolation_level": None, # Use autocommit mode
179
+ }
180
+ )
181
+ # Use NullPool for Windows filesystem databases to avoid connection pooling issues
182
+ # Important: Do NOT use NullPool for in-memory databases as it will destroy the database
183
+ # between connections
184
+ if db_type == DatabaseType.FILESYSTEM:
185
+ engine = create_async_engine(
186
+ db_url,
187
+ connect_args=connect_args,
188
+ poolclass=NullPool, # Disable connection pooling on Windows
189
+ echo=False,
190
+ )
191
+ else:
192
+ # In-memory databases need connection pooling to maintain state
193
+ engine = create_async_engine(db_url, connect_args=connect_args)
194
+ else:
195
+ engine = create_async_engine(db_url, connect_args=connect_args)
196
+
197
+ # Enable WAL mode for better concurrency and reliability
198
+ # Note: WAL mode is not supported for in-memory databases
199
+ enable_wal = db_type != DatabaseType.MEMORY
200
+
201
+ @event.listens_for(engine.sync_engine, "connect")
202
+ def enable_wal_mode(dbapi_conn, connection_record):
203
+ """Enable WAL mode on each connection."""
204
+ _configure_sqlite_connection(dbapi_conn, enable_wal=enable_wal)
205
+
206
+ return engine
207
+
208
+
209
+ def _create_postgres_engine(db_url: str, config: BasicMemoryConfig) -> AsyncEngine:
210
+ """Create Postgres async engine with appropriate configuration.
211
+
212
+ Args:
213
+ db_url: Postgres connection URL (postgresql+asyncpg://...)
214
+ config: BasicMemoryConfig with pool settings
215
+
216
+ Returns:
217
+ Configured async engine for Postgres
218
+ """
219
+ # Use NullPool connection issues.
220
+ # Assume connection pooler like PgBouncer handles connection pooling.
221
+ engine = create_async_engine(
222
+ db_url,
223
+ echo=False,
224
+ poolclass=NullPool, # No pooling - fresh connection per request
225
+ connect_args={
226
+ # Disable statement cache to avoid issues with prepared statements on reconnect
227
+ "statement_cache_size": 0,
228
+ # Allow 30s for commands (Neon cold start can take 2-5s, sometimes longer)
229
+ "command_timeout": 30,
230
+ # Allow 30s for initial connection (Neon wake-up time)
231
+ "timeout": 30,
232
+ "server_settings": {
233
+ "application_name": "basic-memory",
234
+ # Statement timeout for queries (30s to allow for cold start)
235
+ "statement_timeout": "30s",
236
+ },
237
+ },
238
+ )
239
+ logger.debug("Created Postgres engine with NullPool (no connection pooling)")
240
+
241
+ return engine
242
+
243
+
244
+ def _create_engine_and_session(
245
+ db_path: Path, db_type: DatabaseType = DatabaseType.FILESYSTEM
246
+ ) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
247
+ """Internal helper to create engine and session maker.
248
+
249
+ Args:
250
+ db_path: Path to database file (used for SQLite, ignored for Postgres)
251
+ db_type: Type of database (MEMORY, FILESYSTEM, or POSTGRES)
252
+
253
+ Returns:
254
+ Tuple of (engine, session_maker)
255
+ """
256
+ config = ConfigManager().config
257
+ db_url = DatabaseType.get_db_url(db_path, db_type, config)
258
+ logger.debug(f"Creating engine for db_url: {db_url}")
259
+
260
+ # Delegate to backend-specific engine creation
261
+ # Check explicit POSTGRES type first, then config setting
262
+ if db_type == DatabaseType.POSTGRES or config.database_backend == DatabaseBackend.POSTGRES:
263
+ engine = _create_postgres_engine(db_url, config)
264
+ else:
265
+ engine = _create_sqlite_engine(db_url, db_type)
266
+
267
+ session_maker = async_sessionmaker(engine, expire_on_commit=False)
268
+ return engine, session_maker
269
+
270
+
271
+ async def get_or_create_db(
272
+ db_path: Path,
273
+ db_type: DatabaseType = DatabaseType.FILESYSTEM,
274
+ ensure_migrations: bool = True,
275
+ ) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: # pragma: no cover
276
+ """Get or create database engine and session maker."""
277
+ global _engine, _session_maker
278
+
279
+ if _engine is None:
280
+ _engine, _session_maker = _create_engine_and_session(db_path, db_type)
281
+
282
+ # Run migrations automatically unless explicitly disabled
283
+ if ensure_migrations:
284
+ app_config = ConfigManager().config
285
+ await run_migrations(app_config, db_type)
286
+
287
+ # These checks should never fail since we just created the engine and session maker
288
+ # if they were None, but we'll check anyway for the type checker
289
+ if _engine is None:
290
+ logger.error("Failed to create database engine", db_path=str(db_path))
291
+ raise RuntimeError("Database engine initialization failed")
292
+
293
+ if _session_maker is None:
294
+ logger.error("Failed to create session maker", db_path=str(db_path))
295
+ raise RuntimeError("Session maker initialization failed")
296
+
297
+ return _engine, _session_maker
298
+
299
+
300
+ async def shutdown_db() -> None: # pragma: no cover
301
+ """Clean up database connections."""
302
+ global _engine, _session_maker
303
+
304
+ if _engine:
305
+ await _engine.dispose()
306
+ _engine = None
307
+ _session_maker = None
308
+
309
+
310
+ @asynccontextmanager
311
+ async def engine_session_factory(
312
+ db_path: Path,
313
+ db_type: DatabaseType = DatabaseType.MEMORY,
314
+ ) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
315
+ """Create engine and session factory.
316
+
317
+ Note: This is primarily used for testing where we want a fresh database
318
+ for each test. For production use, use get_or_create_db() instead.
319
+ """
320
+
321
+ global _engine, _session_maker
322
+
323
+ # Use the same helper function as production code
324
+ _engine, _session_maker = _create_engine_and_session(db_path, db_type)
325
+
326
+ try:
327
+ # Verify that engine and session maker are initialized
328
+ if _engine is None: # pragma: no cover
329
+ logger.error("Database engine is None in engine_session_factory")
330
+ raise RuntimeError("Database engine initialization failed")
331
+
332
+ if _session_maker is None: # pragma: no cover
333
+ logger.error("Session maker is None in engine_session_factory")
334
+ raise RuntimeError("Session maker initialization failed")
335
+
336
+ yield _engine, _session_maker
337
+ finally:
338
+ if _engine:
339
+ await _engine.dispose()
340
+ _engine = None
341
+ _session_maker = None
342
+
343
+
344
+ async def run_migrations(
345
+ app_config: BasicMemoryConfig, database_type=DatabaseType.FILESYSTEM
346
+ ): # pragma: no cover
347
+ """Run any pending alembic migrations.
348
+
349
+ Note: Alembic tracks which migrations have been applied via the alembic_version table,
350
+ so it's safe to call this multiple times - it will only run pending migrations.
351
+ """
352
+ logger.info("Running database migrations...")
353
+ try:
354
+ # Get the absolute path to the alembic directory relative to this file
355
+ alembic_dir = Path(__file__).parent / "alembic"
356
+ config = Config()
357
+
358
+ # Set required Alembic config options programmatically
359
+ config.set_main_option("script_location", str(alembic_dir))
360
+ config.set_main_option(
361
+ "file_template",
362
+ "%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s",
363
+ )
364
+ config.set_main_option("timezone", "UTC")
365
+ config.set_main_option("revision_environment", "false")
366
+
367
+ # Get the correct database URL based on backend configuration
368
+ # No URL conversion needed - env.py now handles both async and sync engines
369
+ db_url = DatabaseType.get_db_url(app_config.database_path, database_type, app_config)
370
+ config.set_main_option("sqlalchemy.url", db_url)
371
+
372
+ command.upgrade(config, "head")
373
+ logger.info("Migrations completed successfully")
374
+
375
+ # Get session maker - ensure we don't trigger recursive migration calls
376
+ if _session_maker is None:
377
+ _, session_maker = _create_engine_and_session(app_config.database_path, database_type)
378
+ else:
379
+ session_maker = _session_maker
380
+
381
+ # Initialize the search index schema
382
+ # For SQLite: Create FTS5 virtual table
383
+ # For Postgres: No-op (tsvector column added by migrations)
384
+ # The project_id is not used for init_search_index, so we pass a dummy value
385
+ if (
386
+ database_type == DatabaseType.POSTGRES
387
+ or app_config.database_backend == DatabaseBackend.POSTGRES
388
+ ):
389
+ await PostgresSearchRepository(session_maker, 1).init_search_index()
390
+ else:
391
+ await SQLiteSearchRepository(session_maker, 1).init_search_index()
392
+ except Exception as e: # pragma: no cover
393
+ logger.error(f"Error running migrations: {e}")
394
+ raise