basic-memory 0.7.0__py3-none-any.whl → 0.17.4__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 (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  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 +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  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 +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -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/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/__init__.py CHANGED
@@ -1,3 +1,7 @@
1
1
  """basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
2
2
 
3
- __version__ = "0.7.0"
3
+ # Package version - updated by release automation
4
+ __version__ = "0.17.4"
5
+
6
+ # API version for FastAPI - independent of package version
7
+ __api_version__ = "v0"
@@ -0,0 +1,119 @@
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts
5
+ # Use forward slashes (/) also on windows to provide an os agnostic path
6
+ script_location = .
7
+
8
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
9
+ # Uncomment the line below if you want the files to be prepended with date and time
10
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
11
+ # for all available tokens
12
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
13
+
14
+ # sys.path path, will be prepended to sys.path if present.
15
+ # defaults to the current working directory.
16
+ prepend_sys_path = .
17
+
18
+ # timezone to use when rendering the date within the migration file
19
+ # as well as the filename.
20
+ # If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
21
+ # Any required deps can installed by adding `alembic[tz]` to the pip requirements
22
+ # string value is passed to ZoneInfo()
23
+ # leave blank for localtime
24
+ # timezone =
25
+
26
+ # max length of characters to apply to the "slug" field
27
+ # truncate_slug_length = 40
28
+
29
+ # set to 'true' to run the environment during
30
+ # the 'revision' command, regardless of autogenerate
31
+ # revision_environment = false
32
+
33
+ # set to 'true' to allow .pyc and .pyo files without
34
+ # a source .py file to be detected as revisions in the
35
+ # versions/ directory
36
+ # sourceless = false
37
+
38
+ # version location specification; This defaults
39
+ # to migrations/versions. When using multiple version
40
+ # directories, initial revisions must be specified with --version-path.
41
+ # The path separator used here should be the separator specified by "version_path_separator" below.
42
+ # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
43
+
44
+ # version path separator; As mentioned above, this is the character used to split
45
+ # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
46
+ # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
47
+ # Valid values for version_path_separator are:
48
+ #
49
+ # version_path_separator = :
50
+ # version_path_separator = ;
51
+ # version_path_separator = space
52
+ # version_path_separator = newline
53
+ #
54
+ # Use os.pathsep. Default configuration used for new projects.
55
+ version_path_separator = os
56
+
57
+ # set to 'true' to search source files recursively
58
+ # in each "version_locations" directory
59
+ # new in Alembic version 1.10
60
+ # recursive_version_locations = false
61
+
62
+ # the output encoding used when revision files
63
+ # are written from script.py.mako
64
+ # output_encoding = utf-8
65
+
66
+ sqlalchemy.url = driver://user:pass@localhost/dbname
67
+
68
+
69
+ [post_write_hooks]
70
+ # post_write_hooks defines scripts or Python functions that are run
71
+ # on newly generated revision scripts. See the documentation for further
72
+ # detail and examples
73
+
74
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
75
+ # hooks = black
76
+ # black.type = console_scripts
77
+ # black.entrypoint = black
78
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
79
+
80
+ # lint with attempts to fix using "ruff" - use the exec runner, execute a binary
81
+ # hooks = ruff
82
+ # ruff.type = exec
83
+ # ruff.executable = %(here)s/.venv/bin/ruff
84
+ # ruff.options = --fix REVISION_SCRIPT_FILENAME
85
+
86
+ # Logging configuration
87
+ [loggers]
88
+ keys = root,sqlalchemy,alembic
89
+
90
+ [handlers]
91
+ keys = console
92
+
93
+ [formatters]
94
+ keys = generic
95
+
96
+ [logger_root]
97
+ level = WARNING
98
+ handlers = console
99
+ qualname =
100
+
101
+ [logger_sqlalchemy]
102
+ level = WARNING
103
+ handlers =
104
+ qualname = sqlalchemy.engine
105
+
106
+ [logger_alembic]
107
+ level = INFO
108
+ handlers =
109
+ qualname = alembic
110
+
111
+ [handler_console]
112
+ class = StreamHandler
113
+ args = (sys.stderr,)
114
+ level = NOTSET
115
+ formatter = generic
116
+
117
+ [formatter_generic]
118
+ format = %(levelname)-5.5s [%(name)s] %(message)s
119
+ datefmt = %H:%M:%S
@@ -1,22 +1,56 @@
1
1
  """Alembic environment configuration."""
2
2
 
3
+ import asyncio
4
+ import os
3
5
  from logging.config import fileConfig
4
6
 
5
- from sqlalchemy import engine_from_config
6
- from sqlalchemy import pool
7
+ # Allow nested event loops (needed for pytest-asyncio and other async contexts)
8
+ # Note: nest_asyncio doesn't work with uvloop, so we handle that case separately
9
+ try:
10
+ import nest_asyncio
11
+
12
+ nest_asyncio.apply()
13
+ except (ImportError, ValueError):
14
+ # nest_asyncio not available or can't patch this loop type (e.g., uvloop)
15
+ pass
16
+
17
+ from sqlalchemy import engine_from_config, pool
18
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
7
19
 
8
20
  from alembic import context
9
21
 
10
- from basic_memory.models import Base
11
- from basic_memory.config import config as app_config
22
+ from basic_memory.config import ConfigManager
23
+
24
+ # Trigger: only set test env when actually running under pytest
25
+ # Why: alembic/env.py is imported during normal operations (MCP server startup, migrations)
26
+ # but we only want test behavior during actual test runs
27
+ # Outcome: prevents is_test_env from returning True in production, enabling watch service
28
+ if os.getenv("PYTEST_CURRENT_TEST") is not None:
29
+ os.environ["BASIC_MEMORY_ENV"] = "test"
30
+
31
+ # Import after setting environment variable # noqa: E402
32
+ from basic_memory.models import Base # noqa: E402
12
33
 
13
34
  # this is the Alembic Config object, which provides
14
35
  # access to the values within the .ini file in use.
15
36
  config = context.config
16
37
 
17
- # Set the SQLAlchemy URL from our app config
18
- sqlalchemy_url = f"sqlite:///{app_config.database_path}"
19
- config.set_main_option("sqlalchemy.url", sqlalchemy_url)
38
+ # Load app config - this will read environment variables (BASIC_MEMORY_DATABASE_BACKEND, etc.)
39
+ # due to Pydantic's env_prefix="BASIC_MEMORY_" setting
40
+ app_config = ConfigManager().config
41
+
42
+ # Set the SQLAlchemy URL based on database backend configuration
43
+ # If the URL is already set in config (e.g., from run_migrations), use that
44
+ # Otherwise, get it from app config
45
+ # Note: alembic.ini has a placeholder URL "driver://user:pass@localhost/dbname" that we need to override
46
+ current_url = config.get_main_option("sqlalchemy.url")
47
+ if not current_url or current_url == "driver://user:pass@localhost/dbname":
48
+ from basic_memory.db import DatabaseType
49
+
50
+ sqlalchemy_url = DatabaseType.get_db_url(
51
+ app_config.database_path, DatabaseType.FILESYSTEM, app_config
52
+ )
53
+ config.set_main_option("sqlalchemy.url", sqlalchemy_url)
20
54
 
21
55
  # Interpret the config file for Python logging.
22
56
  if config.config_file_name is not None:
@@ -27,6 +61,14 @@ if config.config_file_name is not None:
27
61
  target_metadata = Base.metadata
28
62
 
29
63
 
64
+ # Add this function to tell Alembic what to include/exclude
65
+ def include_object(object, name, type_, reflected, compare_to):
66
+ # Ignore SQLite FTS tables
67
+ if type_ == "table" and name.startswith("search_index"):
68
+ return False
69
+ return True
70
+
71
+
30
72
  def run_migrations_offline() -> None:
31
73
  """Run migrations in 'offline' mode.
32
74
 
@@ -44,29 +86,97 @@ def run_migrations_offline() -> None:
44
86
  target_metadata=target_metadata,
45
87
  literal_binds=True,
46
88
  dialect_opts={"paramstyle": "named"},
89
+ include_object=include_object,
90
+ render_as_batch=True,
47
91
  )
48
92
 
49
93
  with context.begin_transaction():
50
94
  context.run_migrations()
51
95
 
52
96
 
97
+ def do_run_migrations(connection):
98
+ """Execute migrations with the given connection."""
99
+ context.configure(
100
+ connection=connection,
101
+ target_metadata=target_metadata,
102
+ include_object=include_object,
103
+ render_as_batch=True,
104
+ compare_type=True,
105
+ )
106
+ with context.begin_transaction():
107
+ context.run_migrations()
108
+
109
+
110
+ async def run_async_migrations(connectable):
111
+ """Run migrations asynchronously with AsyncEngine."""
112
+ async with connectable.connect() as connection:
113
+ await connection.run_sync(do_run_migrations)
114
+ await connectable.dispose()
115
+
116
+
53
117
  def run_migrations_online() -> None:
54
118
  """Run migrations in 'online' mode.
55
119
 
56
- In this scenario we need to create an Engine
57
- and associate a connection with the context.
120
+ Supports both sync engines (SQLite) and async engines (PostgreSQL with asyncpg).
58
121
  """
59
- connectable = engine_from_config(
60
- config.get_section(config.config_ini_section, {}),
61
- prefix="sqlalchemy.",
62
- poolclass=pool.NullPool,
63
- )
64
-
65
- with connectable.connect() as connection:
66
- context.configure(connection=connection, target_metadata=target_metadata)
67
-
68
- with context.begin_transaction():
69
- context.run_migrations()
122
+ # Check if a connection/engine was provided (e.g., from run_migrations)
123
+ connectable = context.config.attributes.get("connection", None)
124
+
125
+ if connectable is None:
126
+ # No connection provided, create engine from config
127
+ url = context.config.get_main_option("sqlalchemy.url")
128
+
129
+ # Check if it's an async URL (sqlite+aiosqlite or postgresql+asyncpg)
130
+ if url and ("+asyncpg" in url or "+aiosqlite" in url):
131
+ # Create async engine for asyncpg or aiosqlite
132
+ connectable = create_async_engine(
133
+ url,
134
+ poolclass=pool.NullPool,
135
+ future=True,
136
+ )
137
+ else:
138
+ # Create sync engine for regular sqlite or postgresql
139
+ connectable = engine_from_config(
140
+ context.config.get_section(context.config.config_ini_section, {}),
141
+ prefix="sqlalchemy.",
142
+ poolclass=pool.NullPool,
143
+ )
144
+
145
+ # Handle async engines (PostgreSQL with asyncpg)
146
+ if isinstance(connectable, AsyncEngine):
147
+ # Try to run async migrations
148
+ # nest_asyncio allows asyncio.run() from within event loops, but doesn't work with uvloop
149
+ try:
150
+ asyncio.run(run_async_migrations(connectable))
151
+ except RuntimeError as e:
152
+ if "cannot be called from a running event loop" in str(e):
153
+ # We're in a running event loop (likely uvloop) - need to use a different approach
154
+ # Create a new thread to run the async migrations
155
+ import concurrent.futures
156
+
157
+ def run_in_thread():
158
+ """Run async migrations in a new event loop in a separate thread."""
159
+ new_loop = asyncio.new_event_loop()
160
+ asyncio.set_event_loop(new_loop)
161
+ try:
162
+ new_loop.run_until_complete(run_async_migrations(connectable))
163
+ finally:
164
+ new_loop.close()
165
+
166
+ with concurrent.futures.ThreadPoolExecutor() as executor:
167
+ future = executor.submit(run_in_thread)
168
+ future.result() # Wait for completion and re-raise any exceptions
169
+ else:
170
+ raise
171
+ else:
172
+ # Handle sync engines (SQLite) or sync connections
173
+ if hasattr(connectable, "connect"):
174
+ # It's an engine, get a connection
175
+ with connectable.connect() as connection:
176
+ do_run_migrations(connection)
177
+ else:
178
+ # It's already a connection
179
+ do_run_migrations(connectable)
70
180
 
71
181
 
72
182
  if context.is_offline_mode():
@@ -1,6 +1,5 @@
1
1
  """Functions for managing database migrations."""
2
2
 
3
- import asyncio
4
3
  from pathlib import Path
5
4
  from loguru import logger
6
5
  from alembic.config import Config
@@ -10,20 +9,16 @@ from alembic import command
10
9
  def get_alembic_config() -> Config: # pragma: no cover
11
10
  """Get alembic config with correct paths."""
12
11
  migrations_path = Path(__file__).parent
13
- alembic_ini = migrations_path.parent.parent.parent / "alembic.ini"
12
+ alembic_ini = migrations_path / "alembic.ini"
14
13
 
15
14
  config = Config(alembic_ini)
16
15
  config.set_main_option("script_location", str(migrations_path))
17
16
  return config
18
17
 
19
18
 
20
- async def reset_database(): # pragma: no cover
19
+ def reset_database(): # pragma: no cover
21
20
  """Drop and recreate all tables."""
22
21
  logger.info("Resetting database...")
23
22
  config = get_alembic_config()
24
-
25
- def _reset(cfg):
26
- command.downgrade(cfg, "base")
27
- command.upgrade(cfg, "head")
28
-
29
- await asyncio.get_event_loop().run_in_executor(None, _reset, config)
23
+ command.downgrade(config, "base")
24
+ command.upgrade(config, "head")
@@ -0,0 +1,131 @@
1
+ """Add Postgres full-text search support with tsvector and GIN indexes
2
+
3
+ Revision ID: 314f1ea54dc4
4
+ Revises: e7e1f4367280
5
+ Create Date: 2025-11-15 18:05:01.025405
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "314f1ea54dc4"
17
+ down_revision: Union[str, None] = "e7e1f4367280"
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ """Add PostgreSQL full-text search support.
24
+
25
+ This migration:
26
+ 1. Creates search_index table for Postgres (SQLite uses FTS5 virtual table)
27
+ 2. Adds generated tsvector column for full-text search
28
+ 3. Creates GIN index on the tsvector column for fast text queries
29
+ 4. Creates GIN index on metadata JSONB column for fast containment queries
30
+
31
+ Note: These changes only apply to Postgres. SQLite continues to use FTS5 virtual tables.
32
+ """
33
+ # Check if we're using Postgres
34
+ connection = op.get_bind()
35
+ if connection.dialect.name == "postgresql":
36
+ # Create search_index table for Postgres
37
+ # For SQLite, this is a FTS5 virtual table created elsewhere
38
+ from sqlalchemy.dialects.postgresql import JSONB
39
+
40
+ op.create_table(
41
+ "search_index",
42
+ sa.Column("id", sa.Integer(), nullable=False), # Entity IDs are integers
43
+ sa.Column("project_id", sa.Integer(), nullable=False), # Multi-tenant isolation
44
+ sa.Column("title", sa.Text(), nullable=True),
45
+ sa.Column("content_stems", sa.Text(), nullable=True),
46
+ sa.Column("content_snippet", sa.Text(), nullable=True),
47
+ sa.Column("permalink", sa.String(), nullable=True), # Nullable for non-markdown files
48
+ sa.Column("file_path", sa.String(), nullable=True),
49
+ sa.Column("type", sa.String(), nullable=True),
50
+ sa.Column("from_id", sa.Integer(), nullable=True), # Relation IDs are integers
51
+ sa.Column("to_id", sa.Integer(), nullable=True), # Relation IDs are integers
52
+ sa.Column("relation_type", sa.String(), nullable=True),
53
+ sa.Column("entity_id", sa.Integer(), nullable=True), # Entity IDs are integers
54
+ sa.Column("category", sa.String(), nullable=True),
55
+ sa.Column("metadata", JSONB(), nullable=True), # Use JSONB for Postgres
56
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
57
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
58
+ sa.PrimaryKeyConstraint(
59
+ "id", "type", "project_id"
60
+ ), # Composite key: id can repeat across types
61
+ sa.ForeignKeyConstraint(
62
+ ["project_id"],
63
+ ["project.id"],
64
+ name="fk_search_index_project_id",
65
+ ondelete="CASCADE",
66
+ ),
67
+ if_not_exists=True,
68
+ )
69
+
70
+ # Create index on project_id for efficient multi-tenant queries
71
+ op.create_index(
72
+ "ix_search_index_project_id",
73
+ "search_index",
74
+ ["project_id"],
75
+ unique=False,
76
+ )
77
+
78
+ # Create unique partial index on permalink for markdown files
79
+ # Non-markdown files don't have permalinks, so we use a partial index
80
+ op.execute("""
81
+ CREATE UNIQUE INDEX uix_search_index_permalink_project
82
+ ON search_index (permalink, project_id)
83
+ WHERE permalink IS NOT NULL
84
+ """)
85
+
86
+ # Add tsvector column as a GENERATED ALWAYS column
87
+ # This automatically updates when title or content_stems change
88
+ op.execute("""
89
+ ALTER TABLE search_index
90
+ ADD COLUMN textsearchable_index_col tsvector
91
+ GENERATED ALWAYS AS (
92
+ to_tsvector('english',
93
+ coalesce(title, '') || ' ' ||
94
+ coalesce(content_stems, '')
95
+ )
96
+ ) STORED
97
+ """)
98
+
99
+ # Create GIN index on tsvector column for fast full-text search
100
+ op.create_index(
101
+ "idx_search_index_fts",
102
+ "search_index",
103
+ ["textsearchable_index_col"],
104
+ unique=False,
105
+ postgresql_using="gin",
106
+ )
107
+
108
+ # Create GIN index on metadata JSONB for fast containment queries
109
+ # Using jsonb_path_ops for smaller index size and better performance
110
+ op.execute("""
111
+ CREATE INDEX idx_search_index_metadata_gin
112
+ ON search_index
113
+ USING GIN (metadata jsonb_path_ops)
114
+ """)
115
+
116
+
117
+ def downgrade() -> None:
118
+ """Remove PostgreSQL full-text search support."""
119
+ connection = op.get_bind()
120
+ if connection.dialect.name == "postgresql":
121
+ # Drop indexes first
122
+ op.execute("DROP INDEX IF EXISTS idx_search_index_metadata_gin")
123
+ op.drop_index("idx_search_index_fts", table_name="search_index")
124
+ op.execute("DROP INDEX IF EXISTS uix_search_index_permalink_project")
125
+ op.drop_index("ix_search_index_project_id", table_name="search_index")
126
+
127
+ # Drop the generated column
128
+ op.execute("ALTER TABLE search_index DROP COLUMN IF EXISTS textsearchable_index_col")
129
+
130
+ # Drop the search_index table
131
+ op.drop_table("search_index")
@@ -0,0 +1,51 @@
1
+ """remove required from entity.permalink
2
+
3
+ Revision ID: 502b60eaa905
4
+ Revises: b3c3938bacdb
5
+ Create Date: 2025-02-24 13:33:09.790951
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "502b60eaa905"
17
+ down_revision: Union[str, None] = "b3c3938bacdb"
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ # ### commands auto generated by Alembic - please adjust! ###
24
+ with op.batch_alter_table("entity", schema=None) as batch_op:
25
+ batch_op.alter_column("permalink", existing_type=sa.VARCHAR(), nullable=True)
26
+ batch_op.drop_index("ix_entity_permalink")
27
+ batch_op.create_index(batch_op.f("ix_entity_permalink"), ["permalink"], unique=False)
28
+ batch_op.drop_constraint("uix_entity_permalink", type_="unique")
29
+ batch_op.create_index(
30
+ "uix_entity_permalink",
31
+ ["permalink"],
32
+ unique=True,
33
+ sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
34
+ )
35
+
36
+ # ### end Alembic commands ###
37
+
38
+
39
+ def downgrade() -> None:
40
+ # ### commands auto generated by Alembic - please adjust! ###
41
+ with op.batch_alter_table("entity", schema=None) as batch_op:
42
+ batch_op.drop_index(
43
+ "uix_entity_permalink",
44
+ sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
45
+ )
46
+ batch_op.create_unique_constraint("uix_entity_permalink", ["permalink"])
47
+ batch_op.drop_index(batch_op.f("ix_entity_permalink"))
48
+ batch_op.create_index("ix_entity_permalink", ["permalink"], unique=1)
49
+ batch_op.alter_column("permalink", existing_type=sa.VARCHAR(), nullable=False)
50
+
51
+ # ### end Alembic commands ###
@@ -0,0 +1,120 @@
1
+ """add projects table
2
+
3
+ Revision ID: 5fe1ab1ccebe
4
+ Revises: cc7172b46608
5
+ Create Date: 2025-05-14 09:05:18.214357
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = "5fe1ab1ccebe"
17
+ down_revision: Union[str, None] = "cc7172b46608"
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ # ### commands auto generated by Alembic - please adjust! ###
24
+
25
+ # SQLite FTS5 virtual table handling is SQLite-specific
26
+ # For Postgres, search_index is a regular table managed by ORM
27
+ connection = op.get_bind()
28
+ is_sqlite = connection.dialect.name == "sqlite"
29
+
30
+ op.create_table(
31
+ "project",
32
+ sa.Column("id", sa.Integer(), nullable=False),
33
+ sa.Column("name", sa.String(), nullable=False),
34
+ sa.Column("description", sa.Text(), nullable=True),
35
+ sa.Column("permalink", sa.String(), nullable=False),
36
+ sa.Column("path", sa.String(), nullable=False),
37
+ sa.Column("is_active", sa.Boolean(), nullable=False),
38
+ sa.Column("is_default", sa.Boolean(), nullable=True),
39
+ sa.Column("created_at", sa.DateTime(), nullable=False),
40
+ sa.Column("updated_at", sa.DateTime(), nullable=False),
41
+ sa.PrimaryKeyConstraint("id"),
42
+ sa.UniqueConstraint("is_default"),
43
+ sa.UniqueConstraint("name"),
44
+ sa.UniqueConstraint("permalink"),
45
+ if_not_exists=True,
46
+ )
47
+ with op.batch_alter_table("project", schema=None) as batch_op:
48
+ batch_op.create_index(
49
+ "ix_project_created_at", ["created_at"], unique=False, if_not_exists=True
50
+ )
51
+ batch_op.create_index("ix_project_name", ["name"], unique=True, if_not_exists=True)
52
+ batch_op.create_index("ix_project_path", ["path"], unique=False, if_not_exists=True)
53
+ batch_op.create_index(
54
+ "ix_project_permalink", ["permalink"], unique=True, if_not_exists=True
55
+ )
56
+ batch_op.create_index(
57
+ "ix_project_updated_at", ["updated_at"], unique=False, if_not_exists=True
58
+ )
59
+
60
+ with op.batch_alter_table("entity", schema=None) as batch_op:
61
+ batch_op.add_column(sa.Column("project_id", sa.Integer(), nullable=False))
62
+ batch_op.drop_index(
63
+ "uix_entity_permalink",
64
+ sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
65
+ if is_sqlite
66
+ else None,
67
+ )
68
+ batch_op.drop_index("ix_entity_file_path")
69
+ batch_op.create_index(batch_op.f("ix_entity_file_path"), ["file_path"], unique=False)
70
+ batch_op.create_index("ix_entity_project_id", ["project_id"], unique=False)
71
+ batch_op.create_index(
72
+ "uix_entity_file_path_project", ["file_path", "project_id"], unique=True
73
+ )
74
+ batch_op.create_index(
75
+ "uix_entity_permalink_project",
76
+ ["permalink", "project_id"],
77
+ unique=True,
78
+ sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
79
+ if is_sqlite
80
+ else None,
81
+ )
82
+ batch_op.create_foreign_key("fk_entity_project_id", "project", ["project_id"], ["id"])
83
+
84
+ # drop the search index table. it will be recreated
85
+ # Only drop for SQLite - Postgres uses regular table managed by ORM
86
+ if is_sqlite:
87
+ op.drop_table("search_index")
88
+
89
+ # ### end Alembic commands ###
90
+
91
+
92
+ def downgrade() -> None:
93
+ # ### commands auto generated by Alembic - please adjust! ###
94
+ with op.batch_alter_table("entity", schema=None) as batch_op:
95
+ batch_op.drop_constraint("fk_entity_project_id", type_="foreignkey")
96
+ batch_op.drop_index(
97
+ "uix_entity_permalink_project",
98
+ sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
99
+ )
100
+ batch_op.drop_index("uix_entity_file_path_project")
101
+ batch_op.drop_index("ix_entity_project_id")
102
+ batch_op.drop_index(batch_op.f("ix_entity_file_path"))
103
+ batch_op.create_index("ix_entity_file_path", ["file_path"], unique=1)
104
+ batch_op.create_index(
105
+ "uix_entity_permalink",
106
+ ["permalink"],
107
+ unique=1,
108
+ sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
109
+ )
110
+ batch_op.drop_column("project_id")
111
+
112
+ with op.batch_alter_table("project", schema=None) as batch_op:
113
+ batch_op.drop_index("ix_project_updated_at")
114
+ batch_op.drop_index("ix_project_permalink")
115
+ batch_op.drop_index("ix_project_path")
116
+ batch_op.drop_index("ix_project_name")
117
+ batch_op.drop_index("ix_project_created_at")
118
+
119
+ op.drop_table("project")
120
+ # ### end Alembic commands ###