Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions src/basic_memory/config_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from loguru import logger
from pydantic import AliasChoices, BaseModel, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy.engine import make_url

from basic_memory.config_migrations import (
migrate_legacy_projects,
Expand Down Expand Up @@ -942,6 +943,50 @@ def model_post_init(self, __context: Any) -> None:
elif self.default_project is not None and self.default_project not in self.projects:
self.default_project = next(iter(self.projects.keys()))

def _resolve_sqlite_database_url_path(self) -> Optional[Path]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate SQLite URLs at the configuration boundary

When database_url is set through basic-memory config set or merely loaded from configuration, this helper is not called: the command only runs BasicMemoryConfig.model_validate() before persisting the candidate. Values this code intends to reject, such as a non-SQLite scheme or a SQLite URL with no path, are therefore reported as successfully saved and fail only later when a database path happens to be requested. Move this validation into a model validator so invalid configuration is rejected before it is persisted.

AGENTS.md reference: AGENTS.md:L128-L133

Useful? React with 👍 / 👎.

"""Derive the SQLite database file path from an explicit ``database_url``.

Returns ``None`` when ``database_url`` is unset or the backend is not
SQLite, so ``app_database_path`` falls back to the existing
``data_dir_path / APP_DATABASE_NAME`` default (Postgres deployments
never use this; their ``database_url`` is consumed directly in db.py).

When a SQLite ``database_url`` is set, this is the single place that
parses it, following SQLAlchemy's own convention: three slashes is a
relative path (resolved against the current working directory), four
is absolute (issue #539). This lets each project/worktree point at
its own index file — e.g.
``BASIC_MEMORY_DATABASE_URL=sqlite+aiosqlite:///.basic-memory/memory.db`` —
instead of always sharing the global data-dir database.
"""
if self.database_backend != DatabaseBackend.SQLITE or not self.database_url:
return None

try:
url = make_url(self.database_url)
except Exception as error:
raise ValueError(
f"Invalid database_url for sqlite backend: {self.database_url!r} ({error})"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Redact credentials from URL parse errors

When parsing fails—for example, for a malformed URL such as postgresql://user:secret@host:notaport/db left under the default SQLite backend—the raised error embeds the complete raw database_url. ConfigManager.load_config() then logs and prints that exception, exposing userinfo or credential-bearing query parameters that the repository's normal config displays deliberately redact. Omit the raw URL or pass it through the shared redact_url() helper.

Useful? React with 👍 / 👎.

) from error

if not url.drivername.startswith("sqlite"):
raise ValueError(
"database_url must use a sqlite driver (e.g. "
"'sqlite+aiosqlite:///path/to.db') when database_backend='sqlite'; "
f"got {url.drivername!r}. Set database_backend='postgres' for a "
"Postgres database_url, or unset database_url to use the default "
"SQLite path."
)

if not url.database:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject standard SQLite in-memory URLs

When database_url is sqlite+aiosqlite:///:memory:, make_url() returns ":memory:" as a nonempty database, so this check accepts a configuration the error message explicitly says is unsupported. app_database_path then creates a misleading file named :memory:, while DatabaseType.get_db_url() reconstructs the special in-memory URL, causing all database state to disappear when the process exits. Explicitly reject url.database == ":memory:" and SQLite URI memory variants.

AGENTS.md reference: AGENTS.md:L132-L133

Useful? React with 👍 / 👎.

raise ValueError(
"database_url for the sqlite backend must include a file path, e.g. "
"'sqlite+aiosqlite:///.basic-memory/memory.db' "
"(in-memory SQLite is not supported for the app database)."
)

return Path(url.database)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve or reject SQLite URL query parameters

For SQLite URLs with query options—especially file URIs such as sqlite+aiosqlite:///file:memory.db?mode=ro&uri=truemake_url() stores the options in url.query, but this return keeps only the database component. DatabaseType.get_db_url() subsequently reconstructs a bare URL, silently dropping uri=true, mode=ro, and other options, so SQLite can open or create a different ordinary file rather than the requested database. Carry the parsed URL through engine creation or reject query-bearing URLs instead of silently changing their meaning.

AGENTS.md reference: AGENTS.md:L132-L133

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude custom database files from project indexing

When the configured URL places the database at a visible path inside a watched project—for example, sqlite+aiosqlite:///memory.db while running from that project's root—the local scan and watcher accept the database, memory.db-wal, and memory.db-shm as ordinary non-hidden files. Indexing those changes writes back into the same database, producing further filesystem events and potentially causing continuous watcher churn while adding database artifacts as entities. Exclude the resolved configured database and its SQLite sidecars from scans and watcher events, or reject visible in-project database locations.

Useful? React with 👍 / 👎.


@property
def app_database_path(self) -> Path:
"""Get the path to the app-level database.
Expand All @@ -950,9 +995,13 @@ def app_database_path(self) -> Path:
across all projects.

Uses BASIC_MEMORY_CONFIG_DIR when set so each process/worktree can
isolate both config and database state.
isolate both config and database state. When ``database_url`` is set
to a SQLite URL, that path is used instead — see
``_resolve_sqlite_database_url_path``.
"""
database_path = self.data_dir_path / APP_DATABASE_NAME
database_path = self._resolve_sqlite_database_url_path() or (
self.data_dir_path / APP_DATABASE_NAME
)
Comment on lines +1002 to +1004

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor injected SQLite configuration in database_path

When a host constructs BasicMemoryConfig(database_url=...) programmatically and injects it into ApiContainer, production initialization calls self.config.database_path; that existing property reloads ConfigManager and returns the global config's app_database_path, so this newly derived path on the injected object is ignored unless the URL is independently duplicated in global configuration. Make database_path delegate to self.app_database_path so explicitly provided composition-root configuration controls the database that is opened.

AGENTS.md reference: AGENTS.md:L265-L269

Useful? React with 👍 / 👎.

if not database_path.exists(): # pragma: no cover
database_path.parent.mkdir(parents=True, exist_ok=True)
database_path.touch()
Expand Down
104 changes: 104 additions & 0 deletions tests/db/test_sqlite_database_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""End-to-end coverage for issue #539's SQLite half: BASIC_MEMORY_DATABASE_URL /
config ``database_url`` accepting a ``sqlite+aiosqlite://`` URL and the engine
actually reading/writing the file it points at (not the default
~/.basic-memory/memory.db).

Config-level URL parsing (relative/absolute, error cases) is covered in
tests/test_config.py::TestBasicMemoryConfig - this module proves the engine
layer honors config.app_database_path once it is derived from database_url,
including the WAL/pragma setup in db.py.
"""

from pathlib import Path

import pytest
from sqlalchemy import text

from basic_memory.config import BasicMemoryConfig, DatabaseBackend
from basic_memory.db import DatabaseType, engine_session_factory


@pytest.mark.asyncio
async def test_engine_creates_sqlite_file_at_custom_relative_database_url(tmp_path, monkeypatch):
"""The engine writes to the custom path derived from a relative database_url."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)

config = BasicMemoryConfig(
env="test",
database_backend=DatabaseBackend.SQLITE,
database_url="sqlite+aiosqlite:///.basic-memory/memory.db",
skip_initialization_sync=True,
)

db_path = config.app_database_path
assert db_path == Path(".basic-memory/memory.db")

async with engine_session_factory(db_path, DatabaseType.FILESYSTEM, config) as (
engine,
session_maker,
):
async with session_maker() as session:
# Force a real connection/write so SQLite actually creates the file
# and applies the WAL pragma from _configure_sqlite_connection.
await session.execute(text("CREATE TABLE probe (id INTEGER PRIMARY KEY)"))
await session.execute(text("INSERT INTO probe (id) VALUES (1)"))
await session.commit()

result = await session.execute(text("PRAGMA journal_mode"))
assert result.scalar() == "wal"

resolved_db = tmp_path / ".basic-memory" / "memory.db"
assert resolved_db.exists()
assert resolved_db.stat().st_size > 0

# The default per-user location must never have been created.
assert not (tmp_path / ".basic-memory-home" / "memory.db").exists()


@pytest.mark.asyncio
async def test_engine_creates_sqlite_file_at_custom_absolute_database_url(tmp_path, monkeypatch):
"""The engine writes to the custom path derived from an absolute database_url."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
custom_db = tmp_path / "instance-a" / "index.db"

config = BasicMemoryConfig(
env="test",
database_backend=DatabaseBackend.SQLITE,
database_url=f"sqlite+aiosqlite:///{custom_db}",
skip_initialization_sync=True,
)

db_path = config.app_database_path
assert db_path == custom_db

async with engine_session_factory(db_path, DatabaseType.FILESYSTEM, config) as (
engine,
session_maker,
):
async with session_maker() as session:
await session.execute(text("CREATE TABLE probe (id INTEGER PRIMARY KEY)"))
await session.commit()

assert custom_db.exists()


@pytest.mark.asyncio
async def test_engine_uses_default_path_when_database_url_unset(tmp_path, monkeypatch):
"""Unset database_url keeps the pre-existing default SQLite path unchanged."""
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path / "state"))

config = BasicMemoryConfig(env="test", skip_initialization_sync=True)

db_path = config.app_database_path
assert db_path == tmp_path / "state" / "memory.db"

async with engine_session_factory(db_path, DatabaseType.FILESYSTEM, config) as (
engine,
session_maker,
):
async with session_maker() as session:
await session.execute(text("CREATE TABLE probe (id INTEGER PRIMARY KEY)"))
await session.commit()

assert (tmp_path / "state" / "memory.db").exists()
67 changes: 67 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,73 @@ def test_app_database_path_defaults_to_home_data_dir(self, config_home, monkeypa
assert config.data_dir_path == config_home / ".basic-memory"
assert config.app_database_path == config_home / ".basic-memory" / "memory.db"

def test_app_database_path_from_sqlite_database_url_relative(self, config_home, monkeypatch):
"""A relative sqlite database_url resolves against the cwd (issue #539).

Three slashes = relative path, per SQLAlchemy's own URL convention -
this is what lets a git worktree point BASIC_MEMORY_DATABASE_URL at a
project-local index instead of sharing ~/.basic-memory/memory.db.
"""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
project_dir = config_home / "worktree"
project_dir.mkdir()
monkeypatch.chdir(project_dir)

config = BasicMemoryConfig(database_url="sqlite+aiosqlite:///.basic-memory/memory.db")

assert config.app_database_path == Path(".basic-memory/memory.db")
assert (project_dir / ".basic-memory" / "memory.db").exists()

def test_app_database_path_from_sqlite_database_url_absolute(self, config_home, monkeypatch):
"""An absolute sqlite database_url (four slashes) is used verbatim (issue #539)."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
custom_db = config_home / "custom-index" / "memory.db"

config = BasicMemoryConfig(database_url=f"sqlite+aiosqlite:///{custom_db}")

assert config.app_database_path == custom_db
assert custom_db.exists()
# The default location must not have been touched.
assert not (config_home / ".basic-memory" / "memory.db").exists()

def test_app_database_path_ignores_database_url_for_postgres_backend(
self, config_home, monkeypatch
):
"""database_url only overrides the SQLite path when database_backend='sqlite'.

Postgres deployments keep consuming database_url directly in db.py; this
property must not be repurposed for them (out of scope for #539's
SQLite-only half).
"""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
config = BasicMemoryConfig(
database_backend="postgres",
database_url="postgresql+asyncpg://user:pass@localhost/db",
skip_initialization_sync=True,
)

assert config.app_database_path == config_home / ".basic-memory" / "memory.db"

def test_app_database_path_rejects_non_sqlite_url_for_sqlite_backend(
self, config_home, monkeypatch
):
"""A non-sqlite database_url with the (default) sqlite backend is a clear config error."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
config = BasicMemoryConfig(
database_url="postgresql+asyncpg://user:pass@localhost/db",
)

with pytest.raises(ValueError, match="sqlite driver"):
_ = config.app_database_path

def test_app_database_path_rejects_sqlite_url_without_path(self, config_home, monkeypatch):
"""An in-memory sqlite database_url has no file path - reject it clearly."""
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
config = BasicMemoryConfig(database_url="sqlite+aiosqlite://")

with pytest.raises(ValueError, match="must include a file path"):
_ = config.app_database_path

def test_semantic_embedding_cache_dir_field_stays_none_by_default(
self, config_home, monkeypatch
):
Expand Down
Loading