Merge pull request #203 from ggozad/feat/read-only-db

Read-only mode
This commit is contained in:
Yiorgis Gozadinos 2025-12-19 12:01:48 +02:00 committed by GitHub
commit 34c32f941c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 540 additions and 100 deletions

View file

@ -1,6 +1,14 @@
# Changelog # Changelog
## [Unreleased] ## [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 ### Fixed
- **File Monitor Path Validation**: Monitor now validates directories exist before watching ([#204](https://github.com/ggozad/haiku.rag/issues/204)) - **File Monitor Path Validation**: Monitor now validates directories exist before watching ([#204](https://github.com/ggozad/haiku.rag/issues/204))

View file

@ -6,6 +6,7 @@ The `haiku-rag` CLI provides complete document management functionality.
Global options (must be specified before the command): Global options (must be specified before the command):
- `--config` - Specify custom configuration file - `--config` - Specify custom configuration file
- `--read-only` - Open database in read-only mode (blocks writes, skips upgrades)
- `--version` / `-v` - Show version and exit - `--version` / `-v` - Show version and exit
Per-command options: Per-command options:
@ -17,6 +18,7 @@ The `haiku-rag` CLI provides complete document management functionality.
```bash ```bash
haiku-rag --config /path/to/config.yaml list haiku-rag --config /path/to/config.yaml list
haiku-rag --config /path/to/config.yaml list --db /path/to/custom.db haiku-rag --config /path/to/config.yaml list --db /path/to/custom.db
haiku-rag --read-only search "query"
haiku-rag add -h haiku-rag add -h
``` ```
@ -234,6 +236,9 @@ haiku-rag serve --monitor --mcp --agui
# Custom MCP port # Custom MCP port
haiku-rag serve --mcp --mcp-port 9000 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. See [Server Mode](server.md) for details on available services.

View file

@ -63,8 +63,13 @@ haiku-rag serve --mcp --mcp-port 9000
# stdio transport (for Claude Desktop) # stdio transport (for Claude Desktop)
haiku-rag serve --mcp --stdio 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 ## Claude Desktop Integration
Add to your Claude Desktop configuration (`claude_desktop_config.json`): Add to your Claude Desktop configuration (`claude_desktop_config.json`):

View file

@ -17,11 +17,19 @@ async with HaikuRAG("path/to/database.lancedb", create=True) as client:
async with HaikuRAG("path/to/database.lancedb") as client: async with HaikuRAG("path/to/database.lancedb") as client:
# Your code here # Your code here
pass 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 !!! note
Databases must be explicitly created with `create=True` or via `haiku-rag init` before use. Operations on non-existent databases will raise `FileNotFoundError`. 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 ## Document Management
### Creating Documents ### Creating Documents

View file

@ -35,9 +35,12 @@ logger = logging.getLogger(__name__)
class HaikuRAGApp: 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.db_path = db_path
self.config = config self.config = config
self.read_only = read_only
self.console = Console() self.console = Console()
async def init(self): async def init(self):
@ -213,13 +216,17 @@ class HaikuRAGApp:
) )
async def list_documents(self, filter: str | None = None): 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) documents = await self.client.list_documents(filter=filter)
for doc in documents: for doc in documents:
self._rich_print_document(doc, truncate=True) self._rich_print_document(doc, truncate=True)
async def add_document_from_text(self, text: str, metadata: dict | None = None): 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) doc = await self.client.create_document(text, metadata=metadata)
self._rich_print_document(doc, truncate=True) self._rich_print_document(doc, truncate=True)
self.console.print( self.console.print(
@ -229,7 +236,9 @@ class HaikuRAGApp:
async def add_document_from_source( async def add_document_from_source(
self, source: str, title: str | None = None, metadata: dict | None = None 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( result = await self.client.create_document_from_source(
source, title=title, metadata=metadata source, title=title, metadata=metadata
) )
@ -246,7 +255,9 @@ class HaikuRAGApp:
) )
async def get_document(self, doc_id: str): 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) doc = await self.client.get_document_by_id(doc_id)
if doc is None: if doc is None:
self.console.print(f"[red]Document with id {doc_id} not found.[/red]") 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) self._rich_print_document(doc, truncate=False)
async def delete_document(self, doc_id: str): 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) deleted = await self.client.delete_document(doc_id)
if deleted: if deleted:
self.console.print( self.console.print(
@ -268,7 +281,9 @@ class HaikuRAGApp:
async def search( async def search(
self, query: str, limit: int | None = None, filter: str | None = None 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) results = await self.client.search(query, limit=limit, filter=filter)
if not results: if not results:
self.console.print("[yellow]No results found.[/yellow]") self.console.print("[yellow]No results found.[/yellow]")
@ -280,7 +295,9 @@ class HaikuRAGApp:
"""Display visual grounding images for a chunk.""" """Display visual grounding images for a chunk."""
from textual_image.renderable import Image as RichImage 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) chunk = await self.client.chunk_repository.get_by_id(chunk_id)
if not chunk: if not chunk:
self.console.print(f"[red]Chunk with id {chunk_id} not found.[/red]") self.console.print(f"[red]Chunk with id {chunk_id} not found.[/red]")
@ -325,7 +342,9 @@ class HaikuRAGApp:
verbose: Show verbose output verbose: Show verbose output
filter: SQL WHERE clause to filter documents 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: try:
citations = [] citations = []
if deep: if deep:
@ -394,7 +413,9 @@ class HaikuRAGApp:
verbose: Show AG-UI event stream during execution verbose: Show AG-UI event stream during execution
filter: SQL WHERE clause to filter documents 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: try:
self.console.print("[bold cyan]Starting research[/bold cyan]") self.console.print("[bold cyan]Starting research[/bold cyan]")
self.console.print(f"[bold blue]Question:[/bold blue] {question}") 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 def rebuild(self, mode: RebuildMode = RebuildMode.FULL):
async with HaikuRAG( 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: ) as client:
try: try:
documents = await client.list_documents() documents = await client.list_documents()
@ -521,7 +545,10 @@ class HaikuRAGApp:
"""Run database maintenance: optimize and cleanup table history.""" """Run database maintenance: optimize and cleanup table history."""
try: try:
async with HaikuRAG( 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: ) as client:
await client.vacuum() await client.vacuum()
self.console.print( self.console.print(
@ -534,7 +561,10 @@ class HaikuRAGApp:
"""Create vector index on the chunks table.""" """Create vector index on the chunks table."""
try: try:
async with HaikuRAG( 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: ) as client:
row_count = client.store.chunks_table.count_rows() row_count = client.store.chunks_table.count_rows()
self.console.print(f"Chunks in database: {row_count}") self.console.print(f"Chunks in database: {row_count}")
@ -704,18 +734,27 @@ class HaikuRAGApp:
enable_agui: bool = False, enable_agui: bool = False,
): ):
"""Start the server with selected services.""" """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 = [] tasks = []
# Start file monitor if enabled # Start file monitor if enabled (not available in read-only mode)
if enable_monitor: if enable_monitor:
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 = FileWatcher(client=client, config=self.config)
monitor_task = asyncio.create_task(monitor.observe()) monitor_task = asyncio.create_task(monitor.observe())
tasks.append(monitor_task) tasks.append(monitor_task)
# Start MCP server if enabled # Start MCP server if enabled
if enable_mcp: 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(): async def run_mcp():
if mcp_transport == "stdio": if mcp_transport == "stdio":

View file

@ -26,6 +26,9 @@ cli = typer.Typer(
context_settings={"help_option_names": ["-h", "--help"]}, no_args_is_help=True 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: def create_app(db: Path | None = None) -> HaikuRAGApp:
"""Create HaikuRAGApp with loaded config and resolved database path. """Create HaikuRAGApp with loaded config and resolved database path.
@ -38,7 +41,7 @@ def create_app(db: Path | None = None) -> HaikuRAGApp:
""" """
config = get_config() config = get_config()
db_path = db if db else config.storage.data_dir / "haiku.rag.lancedb" 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(): async def check_version():
@ -72,8 +75,15 @@ def main(
"--config", "--config",
help="Path to YAML configuration file", 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""" """haiku.rag CLI - Vector database RAG system"""
global _read_only
_read_only = read_only
# Load config from --config, local folder, or default directory # Load config from --config, local folder, or default directory
config_path = find_config_file(cli_path=config) config_path = find_config_file(cli_path=config)
if config_path: if config_path:
@ -353,7 +363,7 @@ def research(
from haiku.rag.cli_chat import interactive_research from haiku.rag.cli_chat import interactive_research
from haiku.rag.client import HaikuRAG 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: try:
interactive_research( interactive_research(
client=client, client=client,
@ -521,7 +531,7 @@ def inspect(
raise typer.Exit(1) from e raise typer.Exit(1) from e
db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb" 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( @cli.command(

View file

@ -60,6 +60,7 @@ class HaikuRAG:
config: AppConfig = Config, config: AppConfig = Config,
skip_validation: bool = False, skip_validation: bool = False,
create: bool = False, create: bool = False,
read_only: bool = False,
): ):
"""Initialize the RAG client with a database path. """Initialize the RAG client with a database path.
@ -68,6 +69,7 @@ class HaikuRAG:
config: Configuration to use. Defaults to global Config. config: Configuration to use. Defaults to global Config.
skip_validation: Whether to skip configuration validation on database load. skip_validation: Whether to skip configuration validation on database load.
create: Whether to create the database if it doesn't exist. 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 self._config = config
if db_path is None: if db_path is None:
@ -77,10 +79,16 @@ class HaikuRAG:
config=self._config, config=self._config,
skip_validation=skip_validation, skip_validation=skip_validation,
create=create, create=create,
read_only=read_only,
) )
self.document_repository = DocumentRepository(self.store) self.document_repository = DocumentRepository(self.store)
self.chunk_repository = ChunkRepository(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 def __aenter__(self):
"""Async context manager entry.""" """Async context manager entry."""
return self return self

View file

@ -74,9 +74,10 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover
Binding("c", "show_context", "Context", show=True), 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__() super().__init__()
self.db_path = db_path self.db_path = db_path
self.read_only = read_only
self.client: HaikuRAG | None = None self.client: HaikuRAG | None = None
def compose(self) -> "ComposeResult": def compose(self) -> "ComposeResult":
@ -90,7 +91,9 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover
async def on_mount(self) -> None: async def on_mount(self) -> None:
"""Initialize the app when mounted.""" """Initialize the app when mounted."""
config = get_config() 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__() await self.client.__aenter__()
# Load initial documents # 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)) 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. """Run the inspector TUI.
Args: Args:
db_path: Path to the LanceDB database. If None, uses default from config. 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() config = get_config()
if db_path is None: if db_path is None:
db_path = config.storage.data_dir / "haiku.rag.lancedb" db_path = config.storage.data_dir / "haiku.rag.lancedb"
app = InspectorApp(db_path) app = InspectorApp(db_path, read_only=read_only)
app.run() app.run()

View file

@ -21,10 +21,21 @@ class DocumentResult(BaseModel):
updated_at: str updated_at: str
def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: def create_mcp_server(
"""Create an MCP server with the specified database path.""" 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 = FastMCP("haiku-rag")
# Write tools - only registered when not in read-only mode
if not read_only:
@mcp.tool() @mcp.tool()
async def add_document_from_file( async def add_document_from_file(
file_path: str, file_path: str,
@ -78,13 +89,23 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
except Exception: except Exception:
return None 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() @mcp.tool()
async def search_documents( async def search_documents(
query: str, limit: int | None = None query: str, limit: int | None = None
) -> list[SearchResult]: ) -> list[SearchResult]:
"""Search the RAG system for documents using hybrid search (vector similarity + full-text search).""" """Search the RAG system for documents using hybrid search (vector similarity + full-text search)."""
try: 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) return await rag.search(query, limit=limit)
except Exception: except Exception:
return [] 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: async def get_document(document_id: str) -> DocumentResult | None:
"""Get a document by its ID.""" """Get a document by its ID."""
try: 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) document = await rag.get_document_by_id(document_id)
if document is None: 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. List of DocumentResult instances matching the criteria.
""" """
try: 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) documents = await rag.list_documents(limit, offset, filter)
return [ return [
@ -146,15 +167,6 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
except Exception: except Exception:
return [] 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() @mcp.tool()
async def ask_question( async def ask_question(
question: str, question: str,
@ -172,7 +184,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
The answer as a string. The answer as a string.
""" """
try: 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: if deep:
from haiku.rag.graph.research.dependencies import ResearchContext from haiku.rag.graph.research.dependencies import ResearchContext
from haiku.rag.graph.research.graph import build_research_graph 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.graph import build_research_graph
from haiku.rag.graph.research.state import ResearchDeps, ResearchState 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) graph = build_research_graph(config=config)
context = ResearchContext(original_question=question) context = ResearchContext(original_question=question)
state = ResearchState.from_config(context=context, config=config) state = ResearchState.from_config(context=context, config=config)

View file

@ -1,4 +1,5 @@
from .engine import Store from .engine import Store
from .exceptions import ReadOnlyError
from .models import Chunk, Document from .models import Chunk, Document
__all__ = ["Store", "Chunk", "Document"] __all__ = ["Store", "Chunk", "Document", "ReadOnlyError"]

View file

@ -12,6 +12,7 @@ from pydantic import Field
from haiku.rag.config import AppConfig, Config from haiku.rag.config import AppConfig, Config
from haiku.rag.embeddings import get_embedder from haiku.rag.embeddings import get_embedder
from haiku.rag.store.exceptions import ReadOnlyError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -57,9 +58,11 @@ class Store:
config: AppConfig = Config, config: AppConfig = Config,
skip_validation: bool = False, skip_validation: bool = False,
create: bool = False, create: bool = False,
read_only: bool = False,
): ):
self.db_path: Path = db_path self.db_path: Path = db_path
self._config = config self._config = config
self._read_only = read_only
self.embedder = get_embedder(config=self._config) self.embedder = get_embedder(config=self._config)
self._vacuum_lock = asyncio.Lock() self._vacuum_lock = asyncio.Lock()
@ -87,6 +90,8 @@ class Store:
self._init_tables() self._init_tables()
# Run upgrades only on existing databases, set version for new ones # Run upgrades only on existing databases, set version for new ones
# Skip upgrades in read-only mode (they would fail anyway)
if not read_only:
if is_new_db: if is_new_db:
self._set_initial_version() self._set_initial_version()
else: else:
@ -96,6 +101,16 @@ class Store:
if not skip_validation: if not skip_validation:
self._validate_configuration() 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: async def vacuum(self, retention_seconds: int | None = None) -> None:
"""Optimize and clean up old versions across all tables to reduce disk usage. """Optimize and clean up old versions across all tables to reduce disk usage.
@ -106,7 +121,12 @@ class Store:
Note: Note:
If vacuum is already running, this method returns immediately without blocking. If vacuum is already running, this method returns immediately without blocking.
Use asyncio.create_task(store.vacuum()) for non-blocking background execution. 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( if self._has_cloud_config() and str(self._config.lancedb.uri).startswith(
"db://" "db://"
): ):
@ -317,7 +337,12 @@ class Store:
return "0.0.0" return "0.0.0"
def set_haiku_version(self, version: str) -> None: 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( settings_records = list(
self.settings_table.search().limit(1).to_pydantic(SettingsRecord) self.settings_table.search().limit(1).to_pydantic(SettingsRecord)
) )
@ -343,7 +368,12 @@ class Store:
) )
def recreate_embeddings_table(self) -> None: 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 # Drop and recreate chunks table
try: try:
self.db.drop_table("chunks") self.db.drop_table("chunks")
@ -373,7 +403,12 @@ class Store:
} }
def restore_table_versions(self, versions: dict[str, int]) -> bool: 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.documents_table.restore(int(versions["documents"]))
self.chunks_table.restore(int(versions["chunks"])) self.chunks_table.restore(int(versions["chunks"]))
self.settings_table.restore(int(versions["settings"])) self.settings_table.restore(int(versions["settings"]))

View file

@ -0,0 +1,4 @@
class ReadOnlyError(Exception):
"""Raised when a write operation is attempted on a read-only store."""
pass

View file

@ -42,6 +42,7 @@ class ChunkRepository:
Chunks must have embeddings set before calling this method. Chunks must have embeddings set before calling this method.
Use client._ensure_chunks_embedded() to embed chunks if needed. Use client._ensure_chunks_embedded() to embed chunks if needed.
""" """
self.store._assert_writable()
# Handle single chunk # Handle single chunk
if isinstance(entity, Chunk): if isinstance(entity, Chunk):
assert entity.document_id, "Chunk must have a document_id to be created" 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. Chunk must have embedding set before calling this method.
""" """
self.store._assert_writable()
assert entity.id, "Chunk ID is required for update" assert entity.id, "Chunk ID is required for update"
assert entity.embedding is not None, "Chunk must have an embedding" 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: async def delete(self, entity_id: str) -> bool:
"""Delete a chunk by its ID.""" """Delete a chunk by its ID."""
self.store._assert_writable()
chunk = await self.get_by_id(entity_id) chunk = await self.get_by_id(entity_id)
if chunk is None: if chunk is None:
return False return False
@ -181,6 +184,7 @@ class ChunkRepository:
async def delete_all(self) -> None: async def delete_all(self) -> None:
"""Delete all chunks from the database.""" """Delete all chunks from the database."""
self.store._assert_writable()
# Drop and recreate table to clear all data # Drop and recreate table to clear all data
self.store.db.drop_table("chunks") self.store.db.drop_table("chunks")
self.store.chunks_table = self.store.db.create_table( 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: async def delete_by_document_id(self, document_id: str) -> bool:
"""Delete all chunks for a document.""" """Delete all chunks for a document."""
self.store._assert_writable()
chunks = await self.get_by_document_id(document_id) chunks = await self.get_by_document_id(document_id)
if not chunks: if not chunks:

View file

@ -47,6 +47,7 @@ class DocumentRepository:
async def create(self, entity: Document) -> Document: async def create(self, entity: Document) -> Document:
"""Create a document in the database.""" """Create a document in the database."""
self.store._assert_writable()
# Generate new UUID # Generate new UUID
doc_id = str(uuid4()) doc_id = str(uuid4())
@ -90,6 +91,7 @@ class DocumentRepository:
async def update(self, entity: Document) -> Document: async def update(self, entity: Document) -> Document:
"""Update an existing document.""" """Update an existing document."""
self.store._assert_writable()
from haiku.rag.store.models.document import invalidate_docling_document_cache from haiku.rag.store.models.document import invalidate_docling_document_cache
assert entity.id, "Document ID is required for update" assert entity.id, "Document ID is required for update"
@ -119,6 +121,7 @@ class DocumentRepository:
async def delete(self, entity_id: str) -> bool: async def delete(self, entity_id: str) -> bool:
"""Delete a document by its ID.""" """Delete a document by its ID."""
self.store._assert_writable()
from haiku.rag.store.models.document import invalidate_docling_document_cache from haiku.rag.store.models.document import invalidate_docling_document_cache
# Check if document exists # Check if document exists
@ -181,6 +184,7 @@ class DocumentRepository:
async def delete_all(self) -> None: async def delete_all(self) -> None:
"""Delete all documents from the database.""" """Delete all documents from the database."""
self.store._assert_writable()
# Delete all chunks first # Delete all chunks first
await self.chunk_repository.delete_all() await self.chunk_repository.delete_all()

View file

@ -72,6 +72,7 @@ class SettingsRepository:
def save_current_settings(self) -> None: def save_current_settings(self) -> None:
"""Save the current configuration to the database.""" """Save the current configuration to the database."""
self.store._assert_writable()
current_config = self.store._config.model_dump(mode="json") current_config = self.store._config.model_dump(mode="json")
# Check if settings exist # Check if settings exist

View file

@ -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