Skip to content

Commit 213331f

Browse files
divideby0claude
andcommitted
feat: unify database_url config for Postgres and SQLite
Extend BASIC_MEMORY_DATABASE_URL to support: - SQLite URLs with custom paths for project-local databases - Postgres URLs with ?search_path= for schema isolation Changes: - config.py: Parse SQLite URLs in app_database_path property - db.py: Extract search_path from Postgres URL, pass to server_settings - db.py: Add ensure_schema_exists() for auto-creating schemas - alembic/env.py: Pass version_table_schema for migration tracking - cli/commands/db.py: Handle Postgres case in reset command Closes basicmachines-co#539 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Cedric Hurst <cedric@spantree.net>
1 parent 8072449 commit 213331f

6 files changed

Lines changed: 654 additions & 22 deletions

File tree

‎src/basic_memory/alembic/env.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,50 @@ def run_migrations_offline() -> None:
9898
context.run_migrations()
9999

100100

101+
def get_version_table_schema(connection) -> str | None:
102+
"""Determine the schema for Alembic's version table.
103+
104+
Args:
105+
connection: Database connection
106+
107+
Returns:
108+
Schema name if Postgres with non-public search_path, else None
109+
110+
Why: When using schema isolation, Alembic's alembic_version table should
111+
be in the same schema as the application tables.
112+
"""
113+
if connection.dialect.name != "postgresql":
114+
return None
115+
116+
# Check if database_url has search_path parameter
117+
if not app_config.database_url or "search_path=" not in app_config.database_url:
118+
return None
119+
120+
from urllib.parse import urlparse, parse_qs
121+
122+
parsed = urlparse(app_config.database_url)
123+
query_params = parse_qs(parsed.query)
124+
search_path = query_params.get("search_path", ["public"])[0]
125+
126+
# Only set version_table_schema for non-public schemas
127+
return search_path if search_path != "public" else None
128+
129+
101130
def do_run_migrations(connection):
102131
"""Execute migrations with the given connection."""
132+
# --- Schema-Aware Migration Tracking ---
133+
# Trigger: Postgres with non-public search_path in database_url
134+
# Why: Alembic version table should be in same schema as application tables
135+
# Outcome: version_table_schema passed to context.configure()
136+
version_table_schema = get_version_table_schema(connection)
137+
103138
context.configure(
104139
connection=connection,
105140
target_metadata=target_metadata,
106141
include_object=include_object,
107142
render_as_batch=True,
108143
compare_type=True,
144+
version_table_schema=version_table_schema,
109145
)
110146
with context.begin_transaction():
111147
context.run_migrations()

‎src/basic_memory/cli/commands/db.py‎

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -61,23 +61,34 @@ def reset(
6161
logger.info("Resetting database...")
6262
config_manager = ConfigManager()
6363
app_config = config_manager.config
64-
# Get database path
64+
# Get database path (None for Postgres)
6565
db_path = app_config.app_database_path
6666

67-
# Delete the database file and WAL files if they exist
68-
for suffix in ["", "-shm", "-wal"]:
69-
path = db_path.parent / f"{db_path.name}{suffix}"
70-
if path.exists():
71-
try:
72-
path.unlink()
73-
logger.info(f"Deleted: {path}")
74-
except OSError as e:
75-
console.print(
76-
f"[red]Error:[/red] Cannot delete {path.name}: {e}\n"
77-
"The database may be in use by another process (e.g., MCP server).\n"
78-
"Please close Claude Desktop or any other Basic Memory clients and try again."
79-
)
80-
raise typer.Exit(1)
67+
# --- SQLite vs Postgres Handling ---
68+
# Trigger: db_path is None when using Postgres backend
69+
# Why: Postgres doesn't use a local file, so file deletion doesn't apply
70+
# Outcome: skip file deletion for Postgres, only run migrations
71+
if db_path is not None:
72+
# Delete the database file and WAL files if they exist (SQLite only)
73+
for suffix in ["", "-shm", "-wal"]:
74+
path = db_path.parent / f"{db_path.name}{suffix}"
75+
if path.exists():
76+
try:
77+
path.unlink()
78+
logger.info(f"Deleted: {path}")
79+
except OSError as e:
80+
console.print(
81+
f"[red]Error:[/red] Cannot delete {path.name}: {e}\n"
82+
"The database may be in use by another process (e.g., MCP server).\n"
83+
"Please close Claude Desktop or any other Basic Memory clients and try again."
84+
)
85+
raise typer.Exit(1)
86+
else:
87+
console.print(
88+
"[yellow]Note:[/yellow] Using Postgres backend. "
89+
"Database files cannot be deleted directly.\n"
90+
"Running migrations to recreate tables..."
91+
)
8192

8293
# Create a new empty database (preserves project configuration)
8394
try:

‎src/basic_memory/config.py‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,24 +316,49 @@ def model_post_init(self, __context: Any) -> None:
316316
self.default_project = next(iter(self.projects.keys()))
317317

318318
@property
319-
def app_database_path(self) -> Path:
319+
def app_database_path(self) -> Optional[Path]:
320320
"""Get the path to the app-level database.
321321
322322
This is the single database that will store all knowledge data
323323
across all projects.
324+
325+
Returns:
326+
Path to SQLite database file, or None for Postgres backends.
324327
"""
328+
# --- SQLite URL Handling ---
329+
# Trigger: database_url is set and starts with "sqlite"
330+
# Why: allows project-local SQLite databases via URL configuration
331+
# Outcome: extracts path from URL (e.g., sqlite+aiosqlite:///.basic-memory/memory.db)
332+
if self.database_url:
333+
if self.database_url.startswith("sqlite"):
334+
from urllib.parse import urlparse
335+
336+
parsed = urlparse(self.database_url)
337+
# parsed.path will be "/.basic-memory/memory.db" - strip leading /
338+
path = parsed.path[1:] if parsed.path.startswith("/") else parsed.path
339+
resolved_path = Path(path).resolve()
340+
# Ensure parent directory exists
341+
if not resolved_path.parent.exists(): # pragma: no cover
342+
resolved_path.parent.mkdir(parents=True, exist_ok=True)
343+
return resolved_path
344+
else:
345+
# Postgres or other backend - no file path
346+
return None
347+
348+
# --- Default SQLite Path ---
349+
# No database_url set - use default ~/.basic-memory/memory.db
325350
database_path = Path.home() / DATA_DIR_NAME / APP_DATABASE_NAME
326351
if not database_path.exists(): # pragma: no cover
327352
database_path.parent.mkdir(parents=True, exist_ok=True)
328353
database_path.touch()
329354
return database_path
330355

331356
@property
332-
def database_path(self) -> Path:
357+
def database_path(self) -> Optional[Path]:
333358
"""Get SQLite database path.
334359
335-
Rreturns the app-level database path
336-
for backward compatibility in the codebase.
360+
Returns the app-level database path for backward compatibility.
361+
Returns None when using Postgres backend.
337362
"""
338363

339364
# Load the app-level database path from the global config

‎src/basic_memory/db.py‎

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,15 @@ def get_db_url(
8383
logger.info(f"Using Postgres database: {config.database_url}")
8484
return config.database_url
8585

86-
# SQLite databases
86+
# --- SQLite URL Handling ---
87+
# Trigger: database_url is set with a SQLite URL
88+
# Why: allows custom SQLite paths via URL configuration
89+
# Outcome: use the provided URL instead of constructing from db_path
90+
if config.database_url and config.database_url.startswith("sqlite"):
91+
logger.info(f"Using SQLite database from URL: {config.database_url}")
92+
return config.database_url
93+
94+
# SQLite databases (default behavior)
8795
if db_type == cls.MEMORY:
8896
logger.info("Using in-memory SQLite database")
8997
return "sqlite+aiosqlite://"
@@ -206,6 +214,34 @@ def enable_wal_mode(dbapi_conn, connection_record):
206214
return engine
207215

208216

217+
def extract_search_path_from_url(db_url: str) -> tuple[str, str]:
218+
"""Extract search_path from Postgres URL and return clean URL.
219+
220+
Args:
221+
db_url: Postgres connection URL, possibly with ?search_path=schema
222+
223+
Returns:
224+
Tuple of (clean_url without search_path, search_path value)
225+
226+
Why: asyncpg rejects search_path as a URL query parameter, so we extract it
227+
and pass it via server_settings instead.
228+
"""
229+
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
230+
231+
parsed = urlparse(db_url)
232+
query_params = parse_qs(parsed.query)
233+
234+
# Extract search_path, default to "public"
235+
search_path_list = query_params.pop("search_path", ["public"])
236+
search_path = search_path_list[0] if search_path_list else "public"
237+
238+
# Rebuild URL without search_path
239+
new_query = urlencode(query_params, doseq=True)
240+
clean_url = urlunparse(parsed._replace(query=new_query))
241+
242+
return clean_url, search_path
243+
244+
209245
def _create_postgres_engine(db_url: str, config: BasicMemoryConfig) -> AsyncEngine:
210246
"""Create Postgres async engine with appropriate configuration.
211247
@@ -216,10 +252,16 @@ def _create_postgres_engine(db_url: str, config: BasicMemoryConfig) -> AsyncEngi
216252
Returns:
217253
Configured async engine for Postgres
218254
"""
255+
# --- Extract search_path from URL ---
256+
# Trigger: URL contains ?search_path=schema parameter
257+
# Why: asyncpg rejects search_path as URL param, must pass via server_settings
258+
# Outcome: clean URL for asyncpg, search_path passed to server_settings
259+
clean_url, search_path = extract_search_path_from_url(db_url)
260+
219261
# Use NullPool connection issues.
220262
# Assume connection pooler like PgBouncer handles connection pooling.
221263
engine = create_async_engine(
222-
db_url,
264+
clean_url,
223265
echo=False,
224266
poolclass=NullPool, # No pooling - fresh connection per request
225267
connect_args={
@@ -233,10 +275,12 @@ def _create_postgres_engine(db_url: str, config: BasicMemoryConfig) -> AsyncEngi
233275
"application_name": "basic-memory",
234276
# Statement timeout for queries (30s to allow for cold start)
235277
"statement_timeout": "30s",
278+
# Schema isolation via search_path (extracted from URL or default "public")
279+
"search_path": search_path,
236280
},
237281
},
238282
)
239-
logger.debug("Created Postgres engine with NullPool (no connection pooling)")
283+
logger.debug(f"Created Postgres engine with search_path={search_path}")
240284

241285
return engine
242286

@@ -365,6 +409,44 @@ async def engine_session_factory(
365409
_session_maker = None
366410

367411

412+
def get_search_path_from_config(app_config: BasicMemoryConfig) -> Optional[str]:
413+
"""Extract search_path from config's database_url if present.
414+
415+
Args:
416+
app_config: BasicMemoryConfig with database_url
417+
418+
Returns:
419+
search_path value if present and not "public", else None
420+
"""
421+
if not app_config.database_url:
422+
return None
423+
424+
if not app_config.database_url.startswith("postgresql"):
425+
return None
426+
427+
_, search_path = extract_search_path_from_url(app_config.database_url)
428+
return search_path if search_path != "public" else None
429+
430+
431+
async def ensure_schema_exists(engine: AsyncEngine, schema: str) -> None:
432+
"""Create schema if it doesn't exist (Postgres only).
433+
434+
Args:
435+
engine: AsyncEngine connected to Postgres
436+
schema: Schema name to create
437+
438+
Why: When using search_path for schema isolation, the schema must exist
439+
before migrations can create tables in it.
440+
"""
441+
if not schema or schema == "public":
442+
return
443+
444+
async with engine.begin() as conn:
445+
# Use text() to execute raw SQL - schema names are trusted config values
446+
await conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema}"'))
447+
logger.info(f"Ensured schema exists: {schema}")
448+
449+
368450
async def run_migrations(
369451
app_config: BasicMemoryConfig, database_type=DatabaseType.FILESYSTEM
370452
): # pragma: no cover
@@ -393,6 +475,22 @@ async def run_migrations(
393475
db_url = DatabaseType.get_db_url(app_config.database_path, database_type, app_config)
394476
config.set_main_option("sqlalchemy.url", db_url)
395477

478+
# --- Schema Creation for Postgres ---
479+
# Trigger: Postgres backend with non-public search_path in URL
480+
# Why: schema must exist before Alembic can create tables in it
481+
# Outcome: CREATE SCHEMA IF NOT EXISTS runs before migrations
482+
search_path = get_search_path_from_config(app_config)
483+
if search_path and (
484+
database_type == DatabaseType.POSTGRES
485+
or app_config.database_backend == DatabaseBackend.POSTGRES
486+
):
487+
# Create a temporary engine just for schema creation
488+
temp_engine = _create_postgres_engine(db_url, app_config)
489+
try:
490+
await ensure_schema_exists(temp_engine, search_path)
491+
finally:
492+
await temp_engine.dispose()
493+
396494
command.upgrade(config, "head")
397495
logger.info("Migrations completed successfully")
398496

0 commit comments

Comments
 (0)