basic-memory 0.16.1__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 (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
basic_memory/__init__.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
2
2
 
3
3
  # Package version - updated by release automation
4
- __version__ = "0.16.1"
4
+ __version__ = "0.17.4"
5
5
 
6
6
  # API version for FastAPI - independent of package version
7
7
  __api_version__ = "v0"
@@ -1,17 +1,32 @@
1
1
  """Alembic environment configuration."""
2
2
 
3
+ import asyncio
3
4
  import os
4
5
  from logging.config import fileConfig
5
6
 
6
- from sqlalchemy import engine_from_config
7
- 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
8
19
 
9
20
  from alembic import context
10
21
 
11
22
  from basic_memory.config import ConfigManager
12
23
 
13
- # set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
14
- os.environ["BASIC_MEMORY_ENV"] = "test"
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"
15
30
 
16
31
  # Import after setting environment variable # noqa: E402
17
32
  from basic_memory.models import Base # noqa: E402
@@ -20,12 +35,22 @@ from basic_memory.models import Base # noqa: E402
20
35
  # access to the values within the .ini file in use.
21
36
  config = context.config
22
37
 
38
+ # Load app config - this will read environment variables (BASIC_MEMORY_DATABASE_BACKEND, etc.)
39
+ # due to Pydantic's env_prefix="BASIC_MEMORY_" setting
23
40
  app_config = ConfigManager().config
24
- # Set the SQLAlchemy URL from our app config
25
- sqlalchemy_url = f"sqlite:///{app_config.database_path}"
26
- config.set_main_option("sqlalchemy.url", sqlalchemy_url)
27
41
 
28
- # print(f"Using SQLAlchemy URL: {sqlalchemy_url}")
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)
29
54
 
30
55
  # Interpret the config file for Python logging.
31
56
  if config.config_file_name is not None:
@@ -69,28 +94,89 @@ def run_migrations_offline() -> None:
69
94
  context.run_migrations()
70
95
 
71
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
+
72
117
  def run_migrations_online() -> None:
73
118
  """Run migrations in 'online' mode.
74
119
 
75
- In this scenario we need to create an Engine
76
- and associate a connection with the context.
120
+ Supports both sync engines (SQLite) and async engines (PostgreSQL with asyncpg).
77
121
  """
78
- connectable = engine_from_config(
79
- config.get_section(config.config_ini_section, {}),
80
- prefix="sqlalchemy.",
81
- poolclass=pool.NullPool,
82
- )
83
-
84
- with connectable.connect() as connection:
85
- context.configure(
86
- connection=connection,
87
- target_metadata=target_metadata,
88
- include_object=include_object,
89
- render_as_batch=True,
90
- )
91
-
92
- with context.begin_transaction():
93
- 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)
94
180
 
95
181
 
96
182
  if context.is_offline_mode():
@@ -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")
@@ -21,6 +21,12 @@ depends_on: Union[str, Sequence[str], None] = None
21
21
 
22
22
  def upgrade() -> None:
23
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
+
24
30
  op.create_table(
25
31
  "project",
26
32
  sa.Column("id", sa.Integer(), nullable=False),
@@ -55,7 +61,9 @@ def upgrade() -> None:
55
61
  batch_op.add_column(sa.Column("project_id", sa.Integer(), nullable=False))
56
62
  batch_op.drop_index(
57
63
  "uix_entity_permalink",
58
- sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
64
+ sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
65
+ if is_sqlite
66
+ else None,
59
67
  )
60
68
  batch_op.drop_index("ix_entity_file_path")
61
69
  batch_op.create_index(batch_op.f("ix_entity_file_path"), ["file_path"], unique=False)
@@ -67,12 +75,16 @@ def upgrade() -> None:
67
75
  "uix_entity_permalink_project",
68
76
  ["permalink", "project_id"],
69
77
  unique=True,
70
- sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
78
+ sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
79
+ if is_sqlite
80
+ else None,
71
81
  )
72
82
  batch_op.create_foreign_key("fk_entity_project_id", "project", ["project_id"], ["id"])
73
83
 
74
84
  # drop the search index table. it will be recreated
75
- op.drop_table("search_index")
85
+ # Only drop for SQLite - Postgres uses regular table managed by ORM
86
+ if is_sqlite:
87
+ op.drop_table("search_index")
76
88
 
77
89
  # ### end Alembic commands ###
78
90
 
@@ -25,43 +25,51 @@ def upgrade() -> None:
25
25
  The UNIQUE constraint prevents multiple projects from having is_default=FALSE,
26
26
  which breaks project creation when the service sets is_default=False.
27
27
 
28
- Since SQLite doesn't support dropping specific constraints easily, we'll
29
- recreate the table without the problematic constraint.
28
+ SQLite: Recreate the table without the constraint (no ALTER TABLE support)
29
+ Postgres: Use ALTER TABLE to drop the constraint directly
30
30
  """
31
- # For SQLite, we need to recreate the table without the UNIQUE constraint
32
- # Create a new table without the UNIQUE constraint on is_default
33
- op.create_table(
34
- "project_new",
35
- sa.Column("id", sa.Integer(), nullable=False),
36
- sa.Column("name", sa.String(), nullable=False),
37
- sa.Column("description", sa.Text(), nullable=True),
38
- sa.Column("permalink", sa.String(), nullable=False),
39
- sa.Column("path", sa.String(), nullable=False),
40
- sa.Column("is_active", sa.Boolean(), nullable=False),
41
- sa.Column("is_default", sa.Boolean(), nullable=True), # No UNIQUE constraint!
42
- sa.Column("created_at", sa.DateTime(), nullable=False),
43
- sa.Column("updated_at", sa.DateTime(), nullable=False),
44
- sa.PrimaryKeyConstraint("id"),
45
- sa.UniqueConstraint("name"),
46
- sa.UniqueConstraint("permalink"),
47
- )
48
-
49
- # Copy data from old table to new table
50
- op.execute("INSERT INTO project_new SELECT * FROM project")
51
-
52
- # Drop the old table
53
- op.drop_table("project")
54
-
55
- # Rename the new table
56
- op.rename_table("project_new", "project")
57
-
58
- # Recreate the indexes
59
- with op.batch_alter_table("project", schema=None) as batch_op:
60
- batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
61
- batch_op.create_index("ix_project_name", ["name"], unique=True)
62
- batch_op.create_index("ix_project_path", ["path"], unique=False)
63
- batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
64
- batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
31
+ connection = op.get_bind()
32
+ is_sqlite = connection.dialect.name == "sqlite"
33
+
34
+ if is_sqlite:
35
+ # For SQLite, we need to recreate the table without the UNIQUE constraint
36
+ # Create a new table without the UNIQUE constraint on is_default
37
+ op.create_table(
38
+ "project_new",
39
+ sa.Column("id", sa.Integer(), nullable=False),
40
+ sa.Column("name", sa.String(), nullable=False),
41
+ sa.Column("description", sa.Text(), nullable=True),
42
+ sa.Column("permalink", sa.String(), nullable=False),
43
+ sa.Column("path", sa.String(), nullable=False),
44
+ sa.Column("is_active", sa.Boolean(), nullable=False),
45
+ sa.Column("is_default", sa.Boolean(), nullable=True), # No UNIQUE constraint!
46
+ sa.Column("created_at", sa.DateTime(), nullable=False),
47
+ sa.Column("updated_at", sa.DateTime(), nullable=False),
48
+ sa.PrimaryKeyConstraint("id"),
49
+ sa.UniqueConstraint("name"),
50
+ sa.UniqueConstraint("permalink"),
51
+ )
52
+
53
+ # Copy data from old table to new table
54
+ op.execute("INSERT INTO project_new SELECT * FROM project")
55
+
56
+ # Drop the old table
57
+ op.drop_table("project")
58
+
59
+ # Rename the new table
60
+ op.rename_table("project_new", "project")
61
+
62
+ # Recreate the indexes
63
+ with op.batch_alter_table("project", schema=None) as batch_op:
64
+ batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
65
+ batch_op.create_index("ix_project_name", ["name"], unique=True)
66
+ batch_op.create_index("ix_project_path", ["path"], unique=False)
67
+ batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
68
+ batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
69
+ else:
70
+ # For Postgres, we can simply drop the constraint
71
+ with op.batch_alter_table("project", schema=None) as batch_op:
72
+ batch_op.drop_constraint("project_is_default_key", type_="unique")
65
73
 
66
74
 
67
75
  def downgrade() -> None:
@@ -0,0 +1,24 @@
1
+ """Merge multiple heads
2
+
3
+ Revision ID: 6830751f5fb6
4
+ Revises: a2b3c4d5e6f7, g9a0b3c4d5e6
5
+ Create Date: 2025-12-29 12:46:46.476268
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision: str = '6830751f5fb6'
14
+ down_revision: Union[str, Sequence[str], None] = ('a2b3c4d5e6f7', 'g9a0b3c4d5e6')
15
+ branch_labels: Union[str, Sequence[str], None] = None
16
+ depends_on: Union[str, Sequence[str], None] = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ pass
21
+
22
+
23
+ def downgrade() -> None:
24
+ pass
@@ -0,0 +1,56 @@
1
+ """Add cascade delete FK from search_index to entity
2
+
3
+ Revision ID: a2b3c4d5e6f7
4
+ Revises: f8a9b2c3d4e5
5
+ Create Date: 2025-12-02 07:00:00.000000
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = "a2b3c4d5e6f7"
16
+ down_revision: Union[str, None] = "f8a9b2c3d4e5"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Add FK with CASCADE delete from search_index.entity_id to entity.id.
23
+
24
+ This migration is Postgres-only because:
25
+ - SQLite uses FTS5 virtual tables which don't support foreign keys
26
+ - The FK enables automatic cleanup of search_index entries when entities are deleted
27
+ """
28
+ connection = op.get_bind()
29
+ dialect = connection.dialect.name
30
+
31
+ if dialect == "postgresql":
32
+ # First, clean up any orphaned search_index entries where entity no longer exists
33
+ op.execute("""
34
+ DELETE FROM search_index
35
+ WHERE entity_id IS NOT NULL
36
+ AND entity_id NOT IN (SELECT id FROM entity)
37
+ """)
38
+
39
+ # Add FK with CASCADE - nullable FK allows search_index entries without entity_id
40
+ op.create_foreign_key(
41
+ "fk_search_index_entity_id",
42
+ "search_index",
43
+ "entity",
44
+ ["entity_id"],
45
+ ["id"],
46
+ ondelete="CASCADE",
47
+ )
48
+
49
+
50
+ def downgrade() -> None:
51
+ """Remove the FK constraint."""
52
+ connection = op.get_bind()
53
+ dialect = connection.dialect.name
54
+
55
+ if dialect == "postgresql":
56
+ op.drop_constraint("fk_search_index_entity_id", "search_index", type_="foreignkey")
@@ -21,6 +21,12 @@ depends_on: Union[str, Sequence[str], None] = None
21
21
  def upgrade() -> None:
22
22
  """Upgrade database schema to use new search index with content_stems and content_snippet."""
23
23
 
24
+ # This migration is SQLite-specific (FTS5 virtual tables)
25
+ # For Postgres, the search_index table is created via ORM models
26
+ connection = op.get_bind()
27
+ if connection.dialect.name != "sqlite":
28
+ return
29
+
24
30
  # First, drop the existing search_index table
25
31
  op.execute("DROP TABLE IF EXISTS search_index")
26
32
 
@@ -59,6 +65,13 @@ def upgrade() -> None:
59
65
 
60
66
  def downgrade() -> None:
61
67
  """Downgrade database schema to use old search index."""
68
+
69
+ # This migration is SQLite-specific (FTS5 virtual tables)
70
+ # For Postgres, the search_index table is managed via ORM models
71
+ connection = op.get_bind()
72
+ if connection.dialect.name != "sqlite":
73
+ return
74
+
62
75
  # Drop the updated search_index table
63
76
  op.execute("DROP TABLE IF EXISTS search_index")
64
77