diff --git a/CHANGELOG.md b/CHANGELOG.md index 4650c7d6..ede4f6e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Added + +- **Read-Only Mode**: Global `--read-only` CLI flag for safe database access without modifications + - Blocks all write operations at the Store layer + - Skips database upgrades and settings saves on open + - Excludes write tools (`add_document_*`, `delete_document`) from MCP server + - Disables file monitor with warning when `--read-only` is used with `serve --monitor` + ### Fixed - **File Monitor Path Validation**: Monitor now validates directories exist before watching ([#204](https://github.com/ggozad/haiku.rag/issues/204)) diff --git a/docs/cli.md b/docs/cli.md index ad59fcd9..407337e3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,6 +6,7 @@ The `haiku-rag` CLI provides complete document management functionality. Global options (must be specified before the command): - `--config` - Specify custom configuration file + - `--read-only` - Open database in read-only mode (blocks writes, skips upgrades) - `--version` / `-v` - Show version and exit Per-command options: @@ -17,6 +18,7 @@ The `haiku-rag` CLI provides complete document management functionality. ```bash haiku-rag --config /path/to/config.yaml list haiku-rag --config /path/to/config.yaml list --db /path/to/custom.db + haiku-rag --read-only search "query" haiku-rag add -h ``` @@ -234,6 +236,9 @@ haiku-rag serve --monitor --mcp --agui # Custom MCP port haiku-rag serve --mcp --mcp-port 9000 + +# Read-only mode (excludes write MCP tools, disables monitor) +haiku-rag --read-only serve --mcp ``` See [Server Mode](server.md) for details on available services. diff --git a/docs/mcp.md b/docs/mcp.md index 357ce383..7aa5bc10 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -63,8 +63,13 @@ haiku-rag serve --mcp --mcp-port 9000 # stdio transport (for Claude Desktop) haiku-rag serve --mcp --stdio + +# Read-only mode (excludes write tools) +haiku-rag --read-only serve --mcp --stdio ``` +**Read-only mode:** When `--read-only` is specified, write tools (`add_document_from_file`, `add_document_from_url`, `add_document_from_text`, `delete_document`) are not registered. Only search and query tools remain available. + ## Claude Desktop Integration Add to your Claude Desktop configuration (`claude_desktop_config.json`): diff --git a/docs/python.md b/docs/python.md index f459ae1b..9b9dd31a 100644 --- a/docs/python.md +++ b/docs/python.md @@ -17,11 +17,19 @@ async with HaikuRAG("path/to/database.lancedb", create=True) as client: async with HaikuRAG("path/to/database.lancedb") as client: # Your code here pass + +# Open in read-only mode (blocks writes, skips upgrades) +async with HaikuRAG("path/to/database.lancedb", read_only=True) as client: + results = await client.search("query") # Read operations work + # await client.create_document(...) # Would raise ReadOnlyError ``` !!! note Databases must be explicitly created with `create=True` or via `haiku-rag init` before use. Operations on non-existent databases will raise `FileNotFoundError`. +!!! note + Read-only mode is useful for safely accessing databases without risk of modification. It blocks all write operations, skips database upgrades on open, and prevents settings from being saved. + ## Document Management ### Creating Documents diff --git a/haiku_rag_slim/haiku/rag/app.py b/haiku_rag_slim/haiku/rag/app.py index 7f216fd0..76718817 100644 --- a/haiku_rag_slim/haiku/rag/app.py +++ b/haiku_rag_slim/haiku/rag/app.py @@ -35,9 +35,12 @@ logger = logging.getLogger(__name__) class HaikuRAGApp: - def __init__(self, db_path: Path, config: AppConfig = Config): + def __init__( + self, db_path: Path, config: AppConfig = Config, read_only: bool = False + ): self.db_path = db_path self.config = config + self.read_only = read_only self.console = Console() async def init(self): @@ -213,13 +216,17 @@ class HaikuRAGApp: ) async def list_documents(self, filter: str | None = None): - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: documents = await self.client.list_documents(filter=filter) for doc in documents: self._rich_print_document(doc, truncate=True) async def add_document_from_text(self, text: str, metadata: dict | None = None): - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: doc = await self.client.create_document(text, metadata=metadata) self._rich_print_document(doc, truncate=True) self.console.print( @@ -229,7 +236,9 @@ class HaikuRAGApp: async def add_document_from_source( self, source: str, title: str | None = None, metadata: dict | None = None ): - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: result = await self.client.create_document_from_source( source, title=title, metadata=metadata ) @@ -246,7 +255,9 @@ class HaikuRAGApp: ) async def get_document(self, doc_id: str): - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: doc = await self.client.get_document_by_id(doc_id) if doc is None: self.console.print(f"[red]Document with id {doc_id} not found.[/red]") @@ -254,7 +265,9 @@ class HaikuRAGApp: self._rich_print_document(doc, truncate=False) async def delete_document(self, doc_id: str): - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: deleted = await self.client.delete_document(doc_id) if deleted: self.console.print( @@ -268,7 +281,9 @@ class HaikuRAGApp: async def search( self, query: str, limit: int | None = None, filter: str | None = None ): - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: results = await self.client.search(query, limit=limit, filter=filter) if not results: self.console.print("[yellow]No results found.[/yellow]") @@ -280,7 +295,9 @@ class HaikuRAGApp: """Display visual grounding images for a chunk.""" from textual_image.renderable import Image as RichImage - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: chunk = await self.client.chunk_repository.get_by_id(chunk_id) if not chunk: self.console.print(f"[red]Chunk with id {chunk_id} not found.[/red]") @@ -325,7 +342,9 @@ class HaikuRAGApp: verbose: Show verbose output filter: SQL WHERE clause to filter documents """ - async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as self.client: try: citations = [] if deep: @@ -394,7 +413,9 @@ class HaikuRAGApp: verbose: Show AG-UI event stream during execution filter: SQL WHERE clause to filter documents """ - async with HaikuRAG(db_path=self.db_path, config=self.config) as client: + async with HaikuRAG( + db_path=self.db_path, config=self.config, read_only=self.read_only + ) as client: try: self.console.print("[bold cyan]Starting research[/bold cyan]") self.console.print(f"[bold blue]Question:[/bold blue] {question}") @@ -485,7 +506,10 @@ class HaikuRAGApp: async def rebuild(self, mode: RebuildMode = RebuildMode.FULL): async with HaikuRAG( - db_path=self.db_path, config=self.config, skip_validation=True + db_path=self.db_path, + config=self.config, + skip_validation=True, + read_only=self.read_only, ) as client: try: documents = await client.list_documents() @@ -521,7 +545,10 @@ class HaikuRAGApp: """Run database maintenance: optimize and cleanup table history.""" try: async with HaikuRAG( - db_path=self.db_path, config=self.config, skip_validation=True + db_path=self.db_path, + config=self.config, + skip_validation=True, + read_only=self.read_only, ) as client: await client.vacuum() self.console.print( @@ -534,7 +561,10 @@ class HaikuRAGApp: """Create vector index on the chunks table.""" try: async with HaikuRAG( - db_path=self.db_path, config=self.config, skip_validation=True + db_path=self.db_path, + config=self.config, + skip_validation=True, + read_only=self.read_only, ) as client: row_count = client.store.chunks_table.count_rows() self.console.print(f"Chunks in database: {row_count}") @@ -704,18 +734,27 @@ class HaikuRAGApp: enable_agui: bool = False, ): """Start the server with selected services.""" - async with HaikuRAG(self.db_path, config=self.config) as client: + async with HaikuRAG( + self.db_path, config=self.config, read_only=self.read_only + ) as client: tasks = [] - # Start file monitor if enabled + # Start file monitor if enabled (not available in read-only mode) if enable_monitor: - monitor = FileWatcher(client=client, config=self.config) - monitor_task = asyncio.create_task(monitor.observe()) - tasks.append(monitor_task) + if self.read_only: + logger.warning( + "File monitor disabled: cannot monitor files in read-only mode" + ) + else: + monitor = FileWatcher(client=client, config=self.config) + monitor_task = asyncio.create_task(monitor.observe()) + tasks.append(monitor_task) # Start MCP server if enabled if enable_mcp: - server = create_mcp_server(self.db_path, config=self.config) + server = create_mcp_server( + self.db_path, config=self.config, read_only=self.read_only + ) async def run_mcp(): if mcp_transport == "stdio": diff --git a/haiku_rag_slim/haiku/rag/cli.py b/haiku_rag_slim/haiku/rag/cli.py index bd1af435..236899df 100644 --- a/haiku_rag_slim/haiku/rag/cli.py +++ b/haiku_rag_slim/haiku/rag/cli.py @@ -26,6 +26,9 @@ cli = typer.Typer( context_settings={"help_option_names": ["-h", "--help"]}, no_args_is_help=True ) +# Module-level read-only flag set by callback +_read_only: bool = False + def create_app(db: Path | None = None) -> HaikuRAGApp: """Create HaikuRAGApp with loaded config and resolved database path. @@ -38,7 +41,7 @@ def create_app(db: Path | None = None) -> HaikuRAGApp: """ config = get_config() db_path = db if db else config.storage.data_dir / "haiku.rag.lancedb" - return HaikuRAGApp(db_path=db_path, config=config) + return HaikuRAGApp(db_path=db_path, config=config, read_only=_read_only) async def check_version(): @@ -72,8 +75,15 @@ def main( "--config", help="Path to YAML configuration file", ), + read_only: bool = typer.Option( + False, + "--read-only", + help="Open database in read-only mode", + ), ): """haiku.rag CLI - Vector database RAG system""" + global _read_only + _read_only = read_only # Load config from --config, local folder, or default directory config_path = find_config_file(cli_path=config) if config_path: @@ -353,7 +363,7 @@ def research( from haiku.rag.cli_chat import interactive_research from haiku.rag.client import HaikuRAG - client = HaikuRAG(db_path=app.db_path, config=app.config) + client = HaikuRAG(db_path=app.db_path, config=app.config, read_only=_read_only) try: interactive_research( client=client, @@ -521,7 +531,7 @@ def inspect( raise typer.Exit(1) from e db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb" - run_inspector(db_path) + run_inspector(db_path, read_only=_read_only) @cli.command( diff --git a/haiku_rag_slim/haiku/rag/client.py b/haiku_rag_slim/haiku/rag/client.py index edd3e9f9..cdea33c5 100644 --- a/haiku_rag_slim/haiku/rag/client.py +++ b/haiku_rag_slim/haiku/rag/client.py @@ -60,6 +60,7 @@ class HaikuRAG: config: AppConfig = Config, skip_validation: bool = False, create: bool = False, + read_only: bool = False, ): """Initialize the RAG client with a database path. @@ -68,6 +69,7 @@ class HaikuRAG: config: Configuration to use. Defaults to global Config. skip_validation: Whether to skip configuration validation on database load. create: Whether to create the database if it doesn't exist. + read_only: Whether to open the database in read-only mode. """ self._config = config if db_path is None: @@ -77,10 +79,16 @@ class HaikuRAG: config=self._config, skip_validation=skip_validation, create=create, + read_only=read_only, ) self.document_repository = DocumentRepository(self.store) self.chunk_repository = ChunkRepository(self.store) + @property + def is_read_only(self) -> bool: + """Whether the client is in read-only mode.""" + return self.store.is_read_only + async def __aenter__(self): """Async context manager entry.""" return self diff --git a/haiku_rag_slim/haiku/rag/inspector/app.py b/haiku_rag_slim/haiku/rag/inspector/app.py index d3a9fa94..b76e6f83 100644 --- a/haiku_rag_slim/haiku/rag/inspector/app.py +++ b/haiku_rag_slim/haiku/rag/inspector/app.py @@ -74,9 +74,10 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover Binding("c", "show_context", "Context", show=True), ] - def __init__(self, db_path: Path): + def __init__(self, db_path: Path, read_only: bool = False): super().__init__() self.db_path = db_path + self.read_only = read_only self.client: HaikuRAG | None = None def compose(self) -> "ComposeResult": @@ -90,7 +91,9 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover async def on_mount(self) -> None: """Initialize the app when mounted.""" config = get_config() - self.client = HaikuRAG(db_path=self.db_path, config=config) + self.client = HaikuRAG( + db_path=self.db_path, config=config, read_only=self.read_only + ) await self.client.__aenter__() # Load initial documents @@ -229,15 +232,18 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover await self._switch_modal(ContextModal(chunk=chunk, client=self.client)) -def run_inspector(db_path: Path | None = None) -> None: # pragma: no cover +def run_inspector( + db_path: Path | None = None, read_only: bool = False +) -> None: # pragma: no cover """Run the inspector TUI. Args: db_path: Path to the LanceDB database. If None, uses default from config. + read_only: Whether to open the database in read-only mode. """ config = get_config() if db_path is None: db_path = config.storage.data_dir / "haiku.rag.lancedb" - app = InspectorApp(db_path) + app = InspectorApp(db_path, read_only=read_only) app.run() diff --git a/haiku_rag_slim/haiku/rag/mcp.py b/haiku_rag_slim/haiku/rag/mcp.py index 949d44b1..f002c384 100644 --- a/haiku_rag_slim/haiku/rag/mcp.py +++ b/haiku_rag_slim/haiku/rag/mcp.py @@ -21,70 +21,91 @@ class DocumentResult(BaseModel): updated_at: str -def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: - """Create an MCP server with the specified database path.""" +def create_mcp_server( + db_path: Path, config: AppConfig = Config, read_only: bool = False +) -> FastMCP: + """Create an MCP server with the specified database path. + + Args: + db_path: Path to the database file. + config: Configuration to use. + read_only: If True, write tools (add_document_*, delete_document) are not registered. + """ mcp = FastMCP("haiku-rag") - @mcp.tool() - async def add_document_from_file( - file_path: str, - metadata: dict[str, Any] | None = None, - title: str | None = None, - ) -> str | None: - """Add a document to the RAG system from a file path.""" - try: - async with HaikuRAG(db_path, config=config) as rag: - result = await rag.create_document_from_source( - Path(file_path), title=title, metadata=metadata or {} - ) - # Handle both single document and list of documents (directories) - if isinstance(result, list): - return result[0].id if result else None - return result.id - except Exception: - return None + # Write tools - only registered when not in read-only mode + if not read_only: - @mcp.tool() - async def add_document_from_url( - url: str, metadata: dict[str, Any] | None = None, title: str | None = None - ) -> str | None: - """Add a document to the RAG system from a URL.""" - try: - async with HaikuRAG(db_path, config=config) as rag: - result = await rag.create_document_from_source( - url, title=title, metadata=metadata or {} - ) - # Handle both single document and list of documents - if isinstance(result, list): - return result[0].id if result else None - return result.id - except Exception: - return None + @mcp.tool() + async def add_document_from_file( + file_path: str, + metadata: dict[str, Any] | None = None, + title: str | None = None, + ) -> str | None: + """Add a document to the RAG system from a file path.""" + try: + async with HaikuRAG(db_path, config=config) as rag: + result = await rag.create_document_from_source( + Path(file_path), title=title, metadata=metadata or {} + ) + # Handle both single document and list of documents (directories) + if isinstance(result, list): + return result[0].id if result else None + return result.id + except Exception: + return None - @mcp.tool() - async def add_document_from_text( - content: str, - uri: str | None = None, - metadata: dict[str, Any] | None = None, - title: str | None = None, - ) -> str | None: - """Add a document to the RAG system from text content.""" - try: - async with HaikuRAG(db_path, config=config) as rag: - document = await rag.create_document( - content, uri, title=title, metadata=metadata or {} - ) - return document.id - except Exception: - return None + @mcp.tool() + async def add_document_from_url( + url: str, metadata: dict[str, Any] | None = None, title: str | None = None + ) -> str | None: + """Add a document to the RAG system from a URL.""" + try: + async with HaikuRAG(db_path, config=config) as rag: + result = await rag.create_document_from_source( + url, title=title, metadata=metadata or {} + ) + # Handle both single document and list of documents + if isinstance(result, list): + return result[0].id if result else None + return result.id + except Exception: + return None + @mcp.tool() + async def add_document_from_text( + content: str, + uri: str | None = None, + metadata: dict[str, Any] | None = None, + title: str | None = None, + ) -> str | None: + """Add a document to the RAG system from text content.""" + try: + async with HaikuRAG(db_path, config=config) as rag: + document = await rag.create_document( + content, uri, title=title, metadata=metadata or {} + ) + return document.id + except Exception: + return None + + @mcp.tool() + async def delete_document(document_id: str) -> bool: + """Delete a document by its ID.""" + try: + async with HaikuRAG(db_path, config=config) as rag: + return await rag.delete_document(document_id) + except Exception: + return False + + # Read tools - always registered @mcp.tool() async def search_documents( query: str, limit: int | None = None ) -> list[SearchResult]: """Search the RAG system for documents using hybrid search (vector similarity + full-text search).""" try: - async with HaikuRAG(db_path, config=config) as rag: + async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: return await rag.search(query, limit=limit) except Exception: return [] @@ -93,7 +114,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: async def get_document(document_id: str) -> DocumentResult | None: """Get a document by its ID.""" try: - async with HaikuRAG(db_path, config=config) as rag: + async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: document = await rag.get_document_by_id(document_id) if document is None: @@ -128,7 +149,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: List of DocumentResult instances matching the criteria. """ try: - async with HaikuRAG(db_path, config=config) as rag: + async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: documents = await rag.list_documents(limit, offset, filter) return [ @@ -146,15 +167,6 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: except Exception: return [] - @mcp.tool() - async def delete_document(document_id: str) -> bool: - """Delete a document by its ID.""" - try: - async with HaikuRAG(db_path, config=config) as rag: - return await rag.delete_document(document_id) - except Exception: - return False - @mcp.tool() async def ask_question( question: str, @@ -172,7 +184,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: The answer as a string. """ try: - async with HaikuRAG(db_path, config=config) as rag: + async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: if deep: from haiku.rag.graph.research.dependencies import ResearchContext from haiku.rag.graph.research.graph import build_research_graph @@ -222,7 +234,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: from haiku.rag.graph.research.graph import build_research_graph from haiku.rag.graph.research.state import ResearchDeps, ResearchState - async with HaikuRAG(db_path, config=config) as rag: + async with HaikuRAG(db_path, config=config, read_only=read_only) as rag: graph = build_research_graph(config=config) context = ResearchContext(original_question=question) state = ResearchState.from_config(context=context, config=config) diff --git a/haiku_rag_slim/haiku/rag/store/__init__.py b/haiku_rag_slim/haiku/rag/store/__init__.py index 362c9f66..df965f8f 100644 --- a/haiku_rag_slim/haiku/rag/store/__init__.py +++ b/haiku_rag_slim/haiku/rag/store/__init__.py @@ -1,4 +1,5 @@ from .engine import Store +from .exceptions import ReadOnlyError from .models import Chunk, Document -__all__ = ["Store", "Chunk", "Document"] +__all__ = ["Store", "Chunk", "Document", "ReadOnlyError"] diff --git a/haiku_rag_slim/haiku/rag/store/engine.py b/haiku_rag_slim/haiku/rag/store/engine.py index 5a7cb79d..554b86bd 100644 --- a/haiku_rag_slim/haiku/rag/store/engine.py +++ b/haiku_rag_slim/haiku/rag/store/engine.py @@ -12,6 +12,7 @@ from pydantic import Field from haiku.rag.config import AppConfig, Config from haiku.rag.embeddings import get_embedder +from haiku.rag.store.exceptions import ReadOnlyError logger = logging.getLogger(__name__) @@ -57,9 +58,11 @@ class Store: config: AppConfig = Config, skip_validation: bool = False, create: bool = False, + read_only: bool = False, ): self.db_path: Path = db_path self._config = config + self._read_only = read_only self.embedder = get_embedder(config=self._config) self._vacuum_lock = asyncio.Lock() @@ -87,15 +90,27 @@ class Store: self._init_tables() # Run upgrades only on existing databases, set version for new ones - if is_new_db: - self._set_initial_version() - else: - self._run_upgrades() + # Skip upgrades in read-only mode (they would fail anyway) + if not read_only: + if is_new_db: + self._set_initial_version() + else: + self._run_upgrades() # Validate config compatibility after connection is established if not skip_validation: self._validate_configuration() + @property + def is_read_only(self) -> bool: + """Whether the store is in read-only mode.""" + return self._read_only + + def _assert_writable(self) -> None: + """Raise ReadOnlyError if the store is in read-only mode.""" + if self._read_only: + raise ReadOnlyError("Cannot modify database in read-only mode") + async def vacuum(self, retention_seconds: int | None = None) -> None: """Optimize and clean up old versions across all tables to reduce disk usage. @@ -106,7 +121,12 @@ class Store: Note: If vacuum is already running, this method returns immediately without blocking. Use asyncio.create_task(store.vacuum()) for non-blocking background execution. + + Raises: + ReadOnlyError: If the store is in read-only mode. """ + self._assert_writable() + if self._has_cloud_config() and str(self._config.lancedb.uri).startswith( "db://" ): @@ -317,7 +337,12 @@ class Store: return "0.0.0" def set_haiku_version(self, version: str) -> None: - """Updates the user version in settings.""" + """Updates the user version in settings. + + Raises: + ReadOnlyError: If the store is in read-only mode. + """ + self._assert_writable() settings_records = list( self.settings_table.search().limit(1).to_pydantic(SettingsRecord) ) @@ -343,7 +368,12 @@ class Store: ) def recreate_embeddings_table(self) -> None: - """Recreate the chunks table with current vector dimensions.""" + """Recreate the chunks table with current vector dimensions. + + Raises: + ReadOnlyError: If the store is in read-only mode. + """ + self._assert_writable() # Drop and recreate chunks table try: self.db.drop_table("chunks") @@ -373,7 +403,12 @@ class Store: } def restore_table_versions(self, versions: dict[str, int]) -> bool: - """Restore tables to the provided versions using LanceDB's API.""" + """Restore tables to the provided versions using LanceDB's API. + + Raises: + ReadOnlyError: If the store is in read-only mode. + """ + self._assert_writable() self.documents_table.restore(int(versions["documents"])) self.chunks_table.restore(int(versions["chunks"])) self.settings_table.restore(int(versions["settings"])) diff --git a/haiku_rag_slim/haiku/rag/store/exceptions.py b/haiku_rag_slim/haiku/rag/store/exceptions.py new file mode 100644 index 00000000..4d5c7e7c --- /dev/null +++ b/haiku_rag_slim/haiku/rag/store/exceptions.py @@ -0,0 +1,4 @@ +class ReadOnlyError(Exception): + """Raised when a write operation is attempted on a read-only store.""" + + pass diff --git a/haiku_rag_slim/haiku/rag/store/repositories/chunk.py b/haiku_rag_slim/haiku/rag/store/repositories/chunk.py index 1577c6e0..40cdadae 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/chunk.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/chunk.py @@ -42,6 +42,7 @@ class ChunkRepository: Chunks must have embeddings set before calling this method. Use client._ensure_chunks_embedded() to embed chunks if needed. """ + self.store._assert_writable() # Handle single chunk if isinstance(entity, Chunk): assert entity.document_id, "Chunk must have a document_id to be created" @@ -126,6 +127,7 @@ class ChunkRepository: Chunk must have embedding set before calling this method. """ + self.store._assert_writable() assert entity.id, "Chunk ID is required for update" assert entity.embedding is not None, "Chunk must have an embedding" @@ -145,6 +147,7 @@ class ChunkRepository: async def delete(self, entity_id: str) -> bool: """Delete a chunk by its ID.""" + self.store._assert_writable() chunk = await self.get_by_id(entity_id) if chunk is None: return False @@ -181,6 +184,7 @@ class ChunkRepository: async def delete_all(self) -> None: """Delete all chunks from the database.""" + self.store._assert_writable() # Drop and recreate table to clear all data self.store.db.drop_table("chunks") self.store.chunks_table = self.store.db.create_table( @@ -193,6 +197,7 @@ class ChunkRepository: async def delete_by_document_id(self, document_id: str) -> bool: """Delete all chunks for a document.""" + self.store._assert_writable() chunks = await self.get_by_document_id(document_id) if not chunks: diff --git a/haiku_rag_slim/haiku/rag/store/repositories/document.py b/haiku_rag_slim/haiku/rag/store/repositories/document.py index f9a2c037..3e97042f 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/document.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/document.py @@ -47,6 +47,7 @@ class DocumentRepository: async def create(self, entity: Document) -> Document: """Create a document in the database.""" + self.store._assert_writable() # Generate new UUID doc_id = str(uuid4()) @@ -90,6 +91,7 @@ class DocumentRepository: async def update(self, entity: Document) -> Document: """Update an existing document.""" + self.store._assert_writable() from haiku.rag.store.models.document import invalidate_docling_document_cache assert entity.id, "Document ID is required for update" @@ -119,6 +121,7 @@ class DocumentRepository: async def delete(self, entity_id: str) -> bool: """Delete a document by its ID.""" + self.store._assert_writable() from haiku.rag.store.models.document import invalidate_docling_document_cache # Check if document exists @@ -181,6 +184,7 @@ class DocumentRepository: async def delete_all(self) -> None: """Delete all documents from the database.""" + self.store._assert_writable() # Delete all chunks first await self.chunk_repository.delete_all() diff --git a/haiku_rag_slim/haiku/rag/store/repositories/settings.py b/haiku_rag_slim/haiku/rag/store/repositories/settings.py index 50de331d..4e8b8dc2 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/settings.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/settings.py @@ -72,6 +72,7 @@ class SettingsRepository: def save_current_settings(self) -> None: """Save the current configuration to the database.""" + self.store._assert_writable() current_config = self.store._config.model_dump(mode="json") # Check if settings exist diff --git a/tests/store/test_read_only.py b/tests/store/test_read_only.py new file mode 100644 index 00000000..5c0e7efc --- /dev/null +++ b/tests/store/test_read_only.py @@ -0,0 +1,289 @@ +import pytest + +from haiku.rag.client import HaikuRAG +from haiku.rag.store import ReadOnlyError, Store +from haiku.rag.store.models import Chunk, Document +from haiku.rag.store.repositories.chunk import ChunkRepository +from haiku.rag.store.repositories.document import DocumentRepository +from haiku.rag.store.repositories.settings import SettingsRepository + + +class TestReadOnlyError: + def test_read_only_error_is_exception(self): + """ReadOnlyError should be a subclass of Exception.""" + assert issubclass(ReadOnlyError, Exception) + + def test_read_only_error_can_be_raised(self): + """ReadOnlyError can be raised and caught.""" + with pytest.raises(ReadOnlyError) as exc_info: + raise ReadOnlyError("Cannot modify database in read-only mode") + assert "read-only" in str(exc_info.value) + + +class TestStoreReadOnly: + def test_store_default_is_not_read_only(self, temp_db_path): + """Store defaults to not read-only.""" + store = Store(temp_db_path, create=True) + assert store.is_read_only is False + store.close() + + def test_store_can_be_created_read_only(self, temp_db_path): + """Store can be created with read_only=True.""" + # First create a normal store to initialize the database + store = Store(temp_db_path, create=True) + store.close() + + # Now open in read-only mode + store = Store(temp_db_path, read_only=True) + assert store.is_read_only is True + store.close() + + def test_assert_writable_raises_when_read_only(self, temp_db_path): + """_assert_writable() raises ReadOnlyError when read_only=True.""" + store = Store(temp_db_path, create=True) + store.close() + + store = Store(temp_db_path, read_only=True) + with pytest.raises(ReadOnlyError): + store._assert_writable() + store.close() + + def test_assert_writable_passes_when_not_read_only(self, temp_db_path): + """_assert_writable() does not raise when read_only=False.""" + store = Store(temp_db_path, create=True) + store._assert_writable() # Should not raise + store.close() + + def test_vacuum_raises_when_read_only(self, temp_db_path): + """vacuum() raises ReadOnlyError when read_only=True.""" + store = Store(temp_db_path, create=True) + store.close() + + store = Store(temp_db_path, read_only=True) + with pytest.raises(ReadOnlyError): + import asyncio + + asyncio.get_event_loop().run_until_complete(store.vacuum()) + store.close() + + def test_set_haiku_version_raises_when_read_only(self, temp_db_path): + """set_haiku_version() raises ReadOnlyError when read_only=True.""" + store = Store(temp_db_path, create=True) + store.close() + + store = Store(temp_db_path, read_only=True) + with pytest.raises(ReadOnlyError): + store.set_haiku_version("1.0.0") + store.close() + + def test_recreate_embeddings_table_raises_when_read_only(self, temp_db_path): + """recreate_embeddings_table() raises ReadOnlyError when read_only=True.""" + store = Store(temp_db_path, create=True) + store.close() + + store = Store(temp_db_path, read_only=True) + with pytest.raises(ReadOnlyError): + store.recreate_embeddings_table() + store.close() + + def test_restore_table_versions_raises_when_read_only(self, temp_db_path): + """restore_table_versions() raises ReadOnlyError when read_only=True.""" + store = Store(temp_db_path, create=True) + versions = store.current_table_versions() + store.close() + + store = Store(temp_db_path, read_only=True) + with pytest.raises(ReadOnlyError): + store.restore_table_versions(versions) + store.close() + + +class TestDocumentRepositoryReadOnly: + @pytest.mark.asyncio + async def test_create_raises_when_read_only(self, temp_db_path): + """DocumentRepository.create() raises ReadOnlyError when read_only=True.""" + store = Store(temp_db_path, create=True) + store.close() + + store = Store(temp_db_path, read_only=True) + repo = DocumentRepository(store) + doc = Document(content="test content") + + with pytest.raises(ReadOnlyError): + await repo.create(doc) + store.close() + + @pytest.mark.asyncio + async def test_update_raises_when_read_only(self, temp_db_path): + """DocumentRepository.update() raises ReadOnlyError when read_only=True.""" + # First create a document + store = Store(temp_db_path, create=True) + repo = DocumentRepository(store) + doc = Document(content="test content") + created_doc = await repo.create(doc) + store.close() + + # Try to update in read-only mode + store = Store(temp_db_path, read_only=True) + repo = DocumentRepository(store) + created_doc.content = "updated content" + + with pytest.raises(ReadOnlyError): + await repo.update(created_doc) + store.close() + + @pytest.mark.asyncio + async def test_delete_raises_when_read_only(self, temp_db_path): + """DocumentRepository.delete() raises ReadOnlyError when read_only=True.""" + # First create a document + store = Store(temp_db_path, create=True) + repo = DocumentRepository(store) + doc = Document(content="test content") + created_doc = await repo.create(doc) + assert created_doc.id is not None + doc_id = created_doc.id + store.close() + + # Try to delete in read-only mode + store = Store(temp_db_path, read_only=True) + repo = DocumentRepository(store) + + with pytest.raises(ReadOnlyError): + await repo.delete(doc_id) + store.close() + + @pytest.mark.asyncio + async def test_delete_all_raises_when_read_only(self, temp_db_path): + """DocumentRepository.delete_all() raises ReadOnlyError when read_only=True.""" + store = Store(temp_db_path, create=True) + store.close() + + store = Store(temp_db_path, read_only=True) + repo = DocumentRepository(store) + + with pytest.raises(ReadOnlyError): + await repo.delete_all() + store.close() + + +class TestChunkRepositoryReadOnly: + @pytest.mark.asyncio + async def test_create_raises_when_read_only(self, temp_db_path): + """ChunkRepository.create() raises ReadOnlyError when read_only=True.""" + # First create a document to have a valid document_id + store = Store(temp_db_path, create=True) + doc_repo = DocumentRepository(store) + doc = Document(content="test content") + created_doc = await doc_repo.create(doc) + store.close() + + store = Store(temp_db_path, read_only=True) + repo = ChunkRepository(store) + chunk = Chunk( + content="test chunk", + document_id=created_doc.id, + embedding=[0.0] * store.embedder._vector_dim, + ) + + with pytest.raises(ReadOnlyError): + await repo.create(chunk) + store.close() + + @pytest.mark.asyncio + async def test_delete_by_document_id_raises_when_read_only(self, temp_db_path): + """ChunkRepository.delete_by_document_id() raises ReadOnlyError when read_only=True.""" + store = Store(temp_db_path, create=True) + store.close() + + store = Store(temp_db_path, read_only=True) + repo = ChunkRepository(store) + + with pytest.raises(ReadOnlyError): + await repo.delete_by_document_id("some-id") + store.close() + + @pytest.mark.asyncio + async def test_delete_all_raises_when_read_only(self, temp_db_path): + """ChunkRepository.delete_all() raises ReadOnlyError when read_only=True.""" + store = Store(temp_db_path, create=True) + store.close() + + store = Store(temp_db_path, read_only=True) + repo = ChunkRepository(store) + + with pytest.raises(ReadOnlyError): + await repo.delete_all() + store.close() + + +class TestSettingsRepositoryReadOnly: + def test_save_current_settings_raises_when_read_only(self, temp_db_path): + """SettingsRepository.save_current_settings() raises ReadOnlyError when read_only=True.""" + store = Store(temp_db_path, create=True) + store.close() + + store = Store(temp_db_path, read_only=True) + repo = SettingsRepository(store) + + with pytest.raises(ReadOnlyError): + repo.save_current_settings() + store.close() + + +class TestClientReadOnly: + def test_client_default_is_not_read_only(self, temp_db_path): + """Client defaults to not read-only.""" + client = HaikuRAG(temp_db_path, create=True) + assert client.is_read_only is False + client.close() + + def test_client_can_be_created_read_only(self, temp_db_path): + """Client can be created with read_only=True.""" + client = HaikuRAG(temp_db_path, create=True) + client.close() + + client = HaikuRAG(temp_db_path, read_only=True) + assert client.is_read_only is True + client.close() + + @pytest.mark.asyncio + async def test_client_create_document_raises_when_read_only(self, temp_db_path): + """Client.create_document() raises ReadOnlyError when read_only=True.""" + client = HaikuRAG(temp_db_path, create=True) + client.close() + + async with HaikuRAG(temp_db_path, read_only=True) as client: + with pytest.raises(ReadOnlyError): + await client.create_document("test content") + + @pytest.mark.asyncio + async def test_client_delete_document_raises_when_read_only(self, temp_db_path): + """Client.delete_document() raises ReadOnlyError when read_only=True.""" + async with HaikuRAG(temp_db_path, create=True) as client: + doc = await client.create_document("test content") + assert doc.id is not None + doc_id = doc.id + + async with HaikuRAG(temp_db_path, read_only=True) as client: + with pytest.raises(ReadOnlyError): + await client.delete_document(doc_id) + + @pytest.mark.asyncio + async def test_client_search_works_when_read_only(self, temp_db_path): + """Client.search() works in read-only mode.""" + async with HaikuRAG(temp_db_path, create=True) as client: + await client.create_document("test content about cats") + + async with HaikuRAG(temp_db_path, read_only=True) as client: + results = await client.search("cats") + assert len(results) > 0 + + @pytest.mark.asyncio + async def test_client_list_documents_works_when_read_only(self, temp_db_path): + """Client.list_documents() works in read-only mode.""" + async with HaikuRAG(temp_db_path, create=True) as client: + await client.create_document("test content") + + async with HaikuRAG(temp_db_path, read_only=True) as client: + docs = await client.list_documents() + assert len(docs) == 1