Merge pull request #242 from ggozad/chore/out-out-migration

Require explicit migrate command for database migrations
This commit is contained in:
Yiorgis Gozadinos 2026-01-19 14:53:22 +02:00 committed by GitHub
commit 2f4354d100
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 547 additions and 73 deletions

View file

@ -1,6 +1,15 @@
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
### Changed
- **Explicit Database Migrations**: Database migrations are no longer applied automatically on open
- Opening a database with pending migrations now raises `MigrationRequiredError` with a clear message
- New `haiku-rag migrate` command to explicitly apply pending migrations
- Version-only updates (no schema changes) are applied silently in writable mode
- New `skip_migration_check` parameter on `Store` for tools that need to bypass the check
- `Store.migrate()` method returns list of applied migration descriptions
## [0.26.5] - 2026-01-16 ## [0.26.5] - 2026-01-16
### Added ### Added

View file

@ -305,6 +305,33 @@ haiku-rag init [--db /path/to/your.lancedb]
This creates the database with the configured settings. **All other commands require an existing database** - they will fail with an informative error if the database doesn't exist. This creates the database with the configured settings. **All other commands require an existing database** - they will fail with an informative error if the database doesn't exist.
### Migrate Database
Apply pending database migrations:
```bash
haiku-rag migrate [--db /path/to/your.lancedb]
```
When you upgrade haiku.rag to a new version that includes schema changes, the database requires migration. Opening a database with pending migrations will display an error:
```
Error: Database requires migration from 0.19.0 to 0.26.5. 3 migration(s) pending. Run 'haiku-rag migrate' to upgrade.
```
Run `haiku-rag migrate` to apply the pending migrations. The command shows which migrations were applied:
```
Applied 3 migration(s):
- 0.20.0: Add 'docling_document_json' and 'docling_version' columns
- 0.23.1: Add content_fts column for contextualized FTS search
- 0.25.0: Compress docling_document with gzip
Migration completed successfully.
```
!!! tip
Back up your database before running migrations. While migrations are designed to be safe, having a backup provides peace of mind for production databases.
### Info ### Info
Display database metadata: Display database metadata:

View file

@ -18,7 +18,7 @@ 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) # Open in read-only mode (blocks writes)
async with HaikuRAG("path/to/database.lancedb", read_only=True) as client: async with HaikuRAG("path/to/database.lancedb", read_only=True) as client:
results = await client.search("query") # Read operations work results = await client.search("query") # Read operations work
# await client.create_document(...) # Would raise ReadOnlyError # await client.create_document(...) # Would raise ReadOnlyError
@ -28,7 +28,10 @@ async with HaikuRAG("path/to/database.lancedb", read_only=True) as client:
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 !!! 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. Read-only mode is useful for safely accessing databases without risk of modification. It blocks all write operations and prevents settings from being saved.
!!! warning "Database Migrations"
When upgrading haiku.rag to a version with schema changes, opening an existing database will raise `MigrationRequiredError`. Run `haiku-rag migrate` to apply pending migrations before using the database. See [CLI Database Management](cli.md#migrate-database) for details.
## Document Management ## Document Management

View file

@ -578,6 +578,26 @@ class HaikuRAGApp:
await client.vacuum() await client.vacuum()
self.console.print("[bold green]Vacuum completed successfully.[/bold green]") self.console.print("[bold green]Vacuum completed successfully.[/bold green]")
def migrate(self) -> list[str]:
"""Run pending database migrations.
Returns:
List of descriptions of applied migrations.
"""
from haiku.rag.store.engine import Store
store = Store(
self.db_path,
config=self.config,
skip_validation=True,
skip_migration_check=True,
)
try:
applied = store.migrate()
return applied
finally:
store.close()
async def create_index(self): async def create_index(self):
"""Create vector index on the chunks table.""" """Create vector index on the chunks table."""
async with HaikuRAG( async with HaikuRAG(

View file

@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
import sys
import warnings import warnings
from datetime import datetime from datetime import datetime
from importlib.metadata import version from importlib.metadata import version
@ -18,15 +19,25 @@ from haiku.rag.config import (
set_config, set_config,
) )
from haiku.rag.logging import configure_cli_logging from haiku.rag.logging import configure_cli_logging
from haiku.rag.store.exceptions import MigrationRequiredError
from haiku.rag.utils import is_up_to_date from haiku.rag.utils import is_up_to_date
# Load environment variables from .env file for API keys and service URLs # Load environment variables from .env file for API keys and service URLs
load_dotenv() load_dotenv()
cli = typer.Typer( _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
) )
def cli():
try:
_cli()
except MigrationRequiredError as e:
typer.echo(f"Error: {e}", err=True)
sys.exit(1)
# Module-level flags set by callback # Module-level flags set by callback
_read_only: bool = False _read_only: bool = False
_before: datetime | None = None _before: datetime | None = None
@ -65,7 +76,7 @@ def version_callback(value: bool):
raise typer.Exit() raise typer.Exit()
@cli.callback() @_cli.callback()
def main( def main(
_version: bool = typer.Option( _version: bool = typer.Option(
False, False,
@ -141,7 +152,7 @@ def main(
pass pass
@cli.command("list", help="List all stored documents") @_cli.command("list", help="List all stored documents")
def list_documents( def list_documents(
db: Path | None = typer.Option( db: Path | None = typer.Option(
None, None,
@ -183,7 +194,7 @@ def _parse_meta_options(meta: list[str] | None) -> dict[str, Any]:
return result return result
@cli.command("add", help="Add a document from text input") @_cli.command("add", help="Add a document from text input")
def add_document_text( def add_document_text(
text: str = typer.Argument( text: str = typer.Argument(
help="The text content of the document to add", help="The text content of the document to add",
@ -205,7 +216,7 @@ def add_document_text(
asyncio.run(app.add_document_from_text(text=text, metadata=metadata or None)) asyncio.run(app.add_document_from_text(text=text, metadata=metadata or None))
@cli.command("add-src", help="Add a document from a file path, directory, or URL") @_cli.command("add-src", help="Add a document from a file path, directory, or URL")
def add_document_src( def add_document_src(
source: str = typer.Argument( source: str = typer.Argument(
help="The file path, directory, or URL of the document(s) to add", help="The file path, directory, or URL of the document(s) to add",
@ -236,7 +247,7 @@ def add_document_src(
) )
@cli.command("get", help="Get and display a document by its ID") @_cli.command("get", help="Get and display a document by its ID")
def get_document( def get_document(
doc_id: str = typer.Argument( doc_id: str = typer.Argument(
help="The ID of the document to get", help="The ID of the document to get",
@ -251,7 +262,7 @@ def get_document(
asyncio.run(app.get_document(doc_id=doc_id)) asyncio.run(app.get_document(doc_id=doc_id))
@cli.command("delete", help="Delete a document by its ID") @_cli.command("delete", help="Delete a document by its ID")
def delete_document( def delete_document(
doc_id: str = typer.Argument( doc_id: str = typer.Argument(
help="The ID of the document to delete", help="The ID of the document to delete",
@ -267,10 +278,12 @@ def delete_document(
# Add alias `rm` for delete # Add alias `rm` for delete
cli.command("rm", help="Alias for delete: remove a document by its ID")(delete_document) _cli.command("rm", help="Alias for delete: remove a document by its ID")(
delete_document
)
@cli.command("search", help="Search for documents by a query") @_cli.command("search", help="Search for documents by a query")
def search( def search(
query: str = typer.Argument( query: str = typer.Argument(
help="The search query to use", help="The search query to use",
@ -297,7 +310,7 @@ def search(
asyncio.run(app.search(query=query, limit=limit, filter=filter)) asyncio.run(app.search(query=query, limit=limit, filter=filter))
@cli.command("visualize", help="Show visual grounding for a chunk") @_cli.command("visualize", help="Show visual grounding for a chunk")
def visualize( def visualize(
chunk_id: str = typer.Argument( chunk_id: str = typer.Argument(
help="The ID of the chunk to visualize", help="The ID of the chunk to visualize",
@ -312,7 +325,7 @@ def visualize(
asyncio.run(app.visualize_chunk(chunk_id=chunk_id)) asyncio.run(app.visualize_chunk(chunk_id=chunk_id))
@cli.command("ask", help="Ask a question using the QA agent") @_cli.command("ask", help="Ask a question using the QA agent")
def ask( def ask(
question: str = typer.Argument( question: str = typer.Argument(
help="The question to ask", help="The question to ask",
@ -368,7 +381,7 @@ def ask(
) )
@cli.command("research", help="Run multi-agent research and output a concise report") @_cli.command("research", help="Run multi-agent research and output a concise report")
def research( def research(
question: str = typer.Argument(..., help="The research question to investigate"), question: str = typer.Argument(..., help="The research question to investigate"),
db: Path | None = typer.Option( db: Path | None = typer.Option(
@ -408,14 +421,14 @@ def research(
) )
@cli.command("settings", help="Display current configuration settings") @_cli.command("settings", help="Display current configuration settings")
def settings(): def settings():
config = get_config() config = get_config()
app = HaikuRAGApp(db_path=Path(), config=config) app = HaikuRAGApp(db_path=Path(), config=config)
app.show_settings() app.show_settings()
@cli.command("init-config", help="Generate a YAML configuration file") @_cli.command("init-config", help="Generate a YAML configuration file")
def init_config( def init_config(
output: Path = typer.Argument( output: Path = typer.Argument(
Path("haiku.rag.yaml"), Path("haiku.rag.yaml"),
@ -447,7 +460,7 @@ def init_config(
typer.echo("Edit the file to customize your settings.") typer.echo("Edit the file to customize your settings.")
@cli.command( @_cli.command(
"rebuild", "rebuild",
help="Rebuild the database by deleting all chunks and re-indexing all documents", help="Rebuild the database by deleting all chunks and re-indexing all documents",
) )
@ -485,7 +498,7 @@ def rebuild(
asyncio.run(app.rebuild(mode=mode)) asyncio.run(app.rebuild(mode=mode))
@cli.command("vacuum", help="Optimize and clean up all tables to reduce disk usage") @_cli.command("vacuum", help="Optimize and clean up all tables to reduce disk usage")
def vacuum( def vacuum(
db: Path | None = typer.Option( db: Path | None = typer.Option(
None, None,
@ -497,7 +510,32 @@ def vacuum(
asyncio.run(app.vacuum()) asyncio.run(app.vacuum())
@cli.command("create-index", help="Create vector index for efficient similarity search") @_cli.command("migrate", help="Run pending database migrations")
def migrate(
db: Path | None = typer.Option(
None,
"--db",
help="Path to the LanceDB database file",
),
):
app = create_app(db)
try:
applied = app.migrate()
if applied:
typer.echo(f"Applied {len(applied)} migration(s):")
for desc in applied:
typer.echo(f" - {desc}")
typer.echo("Migration completed successfully.")
else:
typer.echo("No migrations pending. Database is up to date.")
except Exception as e:
typer.echo(f"Migration failed: {e}")
raise typer.Exit(1)
@_cli.command(
"create-index", help="Create vector index for efficient similarity search"
)
def create_index( def create_index(
db: Path | None = typer.Option( db: Path | None = typer.Option(
None, None,
@ -509,7 +547,7 @@ def create_index(
asyncio.run(app.create_index()) asyncio.run(app.create_index())
@cli.command("init", help="Initialize a new database") @_cli.command("init", help="Initialize a new database")
def init_db( def init_db(
db: Path | None = typer.Option( db: Path | None = typer.Option(
None, None,
@ -521,7 +559,7 @@ def init_db(
asyncio.run(app.init()) asyncio.run(app.init())
@cli.command("info", help="Show database info") @_cli.command("info", help="Show database info")
def info( def info(
db: Path | None = typer.Option( db: Path | None = typer.Option(
None, None,
@ -533,7 +571,7 @@ def info(
asyncio.run(app.info()) asyncio.run(app.info())
@cli.command("history", help="Show version history for database tables") @_cli.command("history", help="Show version history for database tables")
def history( def history(
db: Path | None = typer.Option( db: Path | None = typer.Option(
None, None,
@ -557,7 +595,7 @@ def history(
asyncio.run(app.history(table=table, limit=limit)) asyncio.run(app.history(table=table, limit=limit))
@cli.command("download-models", help="Download Docling and Ollama models per config") @_cli.command("download-models", help="Download Docling and Ollama models per config")
def download_models_cmd(): def download_models_cmd():
app = HaikuRAGApp(db_path=Path(), config=get_config()) app = HaikuRAGApp(db_path=Path(), config=get_config())
try: try:
@ -567,7 +605,7 @@ def download_models_cmd():
raise typer.Exit(1) raise typer.Exit(1)
@cli.command("inspect", help="Launch interactive TUI to inspect database contents") @_cli.command("inspect", help="Launch interactive TUI to inspect database contents")
def inspect( def inspect(
db: Path | None = typer.Option( db: Path | None = typer.Option(
None, None,
@ -586,7 +624,7 @@ def inspect(
run_inspector(db_path, read_only=_read_only, before=_before) run_inspector(db_path, read_only=_read_only, before=_before)
@cli.command("chat", help="Launch interactive chat TUI for conversational RAG") @_cli.command("chat", help="Launch interactive chat TUI for conversational RAG")
def chat( def chat(
db: Path | None = typer.Option( db: Path | None = typer.Option(
None, None,
@ -624,7 +662,7 @@ def chat(
) )
@cli.command( @_cli.command(
"serve", "serve",
help="Start haiku.rag server. Use --monitor and/or --mcp to enable services.", help="Start haiku.rag server. Use --monitor and/or --mcp to enable services.",
) )

View file

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

View file

@ -14,7 +14,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 from haiku.rag.store.exceptions import MigrationRequiredError, ReadOnlyError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -84,6 +84,7 @@ class Store:
create: bool = False, create: bool = False,
read_only: bool = False, read_only: bool = False,
before: datetime | None = None, before: datetime | None = None,
skip_migration_check: bool = False,
): ):
self.db_path: Path = db_path self.db_path: Path = db_path
self._config = config self._config = config
@ -129,13 +130,12 @@ class Store:
if before is not None: if before is not None:
self._checkout_tables_before(before) self._checkout_tables_before(before)
# Run upgrades only on existing databases, set version for new ones # Set version for new databases, check migrations for existing ones
# Skip upgrades in read-only mode (they would fail anyway) if is_new_db:
if not self._read_only: if not self._read_only:
if is_new_db:
self._set_initial_version() self._set_initial_version()
else: elif not skip_migration_check:
self._run_upgrades() self._check_migrations()
# Validate config compatibility after connection is established # Validate config compatibility after connection is established
if not skip_validation: if not skip_validation:
@ -371,26 +371,55 @@ class Store:
"""Set the initial version for a new database.""" """Set the initial version for a new database."""
self.set_haiku_version(metadata.version("haiku.rag-slim")) self.set_haiku_version(metadata.version("haiku.rag-slim"))
def _run_upgrades(self): def _check_migrations(self) -> None:
"""Run pending database upgrades.""" """Check if migrations are pending and error or update version accordingly.
try:
from haiku.rag.store.upgrades import run_pending_upgrades
current_version = metadata.version("haiku.rag-slim") Raises:
db_version = self.get_haiku_version() MigrationRequiredError: If migrations are pending.
"""
from haiku.rag.store.upgrades import get_pending_upgrades
run_pending_upgrades(self, db_version, current_version) current_version = metadata.version("haiku.rag-slim")
db_version = self.get_haiku_version()
self.set_haiku_version(current_version) pending = get_pending_upgrades(db_version)
except Exception as e:
# Avoid hard failure on initial connection; log and continue so CLI remains usable. if pending:
logger.warning( # Migrations are pending - require explicit migrate command
"Skipping upgrade due to error (db=%s -> pkg=%s): %s", raise MigrationRequiredError(
self.get_haiku_version(), f"Database requires migration from {db_version} to {current_version}. "
metadata.version("haiku.rag-slim"), f"{len(pending)} migration(s) pending. "
e, "Run 'haiku-rag migrate' to upgrade."
) )
# No pending migrations - update version silently if needed (writable only)
if not self._read_only and db_version != current_version:
self.set_haiku_version(current_version)
def migrate(self) -> list[str]:
"""Run pending database migrations.
Returns:
List of descriptions of applied upgrades.
Raises:
ReadOnlyError: If the store is in read-only mode.
"""
self._assert_writable()
from haiku.rag.store.upgrades import run_pending_upgrades
db_version = self.get_haiku_version()
current_version = metadata.version("haiku.rag-slim")
applied = run_pending_upgrades(self, db_version)
# Update version after successful migration
if applied or db_version != current_version:
self.set_haiku_version(current_version)
return applied
def get_haiku_version(self) -> str: def get_haiku_version(self) -> str:
"""Returns the user version stored in settings.""" """Returns the user version stored in settings."""
settings_records = list( settings_records = list(

View file

@ -2,3 +2,9 @@ class ReadOnlyError(Exception):
"""Raised when a write operation is attempted on a read-only store.""" """Raised when a write operation is attempted on a read-only store."""
pass pass
class MigrationRequiredError(Exception):
"""Database requires migration. Run 'haiku-rag migrate' to upgrade."""
pass

View file

@ -1,10 +1,12 @@
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING
from packaging.version import Version, parse from packaging.version import Version, parse
from haiku.rag.store.engine import Store if TYPE_CHECKING:
from haiku.rag.store.engine import Store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -14,7 +16,7 @@ class Upgrade:
"""Represents a database upgrade step.""" """Represents a database upgrade step."""
version: str version: str
apply: Callable[[Store], None] apply: Callable[["Store"], None]
description: str = "" description: str = ""
@ -22,24 +24,31 @@ class Upgrade:
upgrades: list[Upgrade] = [] upgrades: list[Upgrade] = []
def run_pending_upgrades(store: Store, from_version: str, to_version: str) -> None: def get_pending_upgrades(from_version: str) -> list[Upgrade]:
"""Run upgrades where from_version < step.version <= to_version.""" """Get pending upgrades from the given version.
Returns:
List of Upgrade objects where from_version < upgrade.version,
sorted by version in ascending order.
"""
v_from: Version = parse(from_version) v_from: Version = parse(from_version)
v_to: Version = parse(to_version)
# Ensure that tests/development run available code upgrades even if the
# installed package version hasn't been bumped to include them yet.
if upgrades:
highest_step_version: Version = max(parse(u.version) for u in upgrades)
if highest_step_version > v_to:
v_to = highest_step_version
# Determine applicable steps
sorted_steps = sorted(upgrades, key=lambda u: parse(u.version)) sorted_steps = sorted(upgrades, key=lambda u: parse(u.version))
applicable = [s for s in sorted_steps if v_from < parse(s.version) <= v_to] return [s for s in sorted_steps if v_from < parse(s.version)]
def run_pending_upgrades(store: "Store", from_version: str) -> list[str]:
"""Run upgrades where from_version < step.version.
Returns:
List of descriptions of applied upgrades.
"""
applicable = get_pending_upgrades(from_version)
if applicable: if applicable:
logger.info("%d upgrade step(s) pending", len(applicable)) logger.info("%d upgrade step(s) pending", len(applicable))
applied: list[str] = []
# Apply in ascending order # Apply in ascending order
for idx, step in enumerate(applicable, start=1): for idx, step in enumerate(applicable, start=1):
logger.info( logger.info(
@ -51,6 +60,11 @@ def run_pending_upgrades(store: Store, from_version: str, to_version: str) -> No
) )
step.apply(store) step.apply(store)
logger.info("Completed upgrade %s", step.version) logger.info("Completed upgrade %s", step.version)
applied.append(
f"{step.version}: {step.description}" if step.description else step.version
)
return applied
# Import upgrade modules AFTER Upgrade class is defined to avoid circular imports # Import upgrade modules AFTER Upgrade class is defined to avoid circular imports

View file

@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
from haiku.rag.cli import cli from haiku.rag.cli import _cli as cli
runner = CliRunner() runner = CliRunner()

View file

@ -0,0 +1,191 @@
from importlib import metadata
import pytest
from haiku.rag.store import Store
from haiku.rag.store.exceptions import MigrationRequiredError
class TestMigrationRequiredError:
def test_migration_required_error_is_exception(self):
"""MigrationRequiredError should be a subclass of Exception."""
assert issubclass(MigrationRequiredError, Exception)
def test_migration_required_error_can_be_raised(self):
"""MigrationRequiredError can be raised and caught."""
with pytest.raises(MigrationRequiredError) as exc_info:
raise MigrationRequiredError("Run 'haiku-rag migrate' to upgrade")
assert "migrate" in str(exc_info.value)
class TestMigrationCheck:
def test_new_database_sets_version(self, temp_db_path):
"""New database should set the current package version."""
store = Store(temp_db_path, create=True)
version = store.get_haiku_version()
expected = metadata.version("haiku.rag-slim")
assert version == expected
store.close()
def test_existing_database_same_version_no_error(self, temp_db_path):
"""Opening a database with the same version should not error."""
store = Store(temp_db_path, create=True)
store.close()
# Re-open - should work without error
store = Store(temp_db_path)
store.close()
def test_version_bump_without_pending_migrations_updates_silently(
self, temp_db_path
):
"""When version is outdated but no migrations pending, update version silently."""
store = Store(temp_db_path, create=True)
# Set an older version that has no pending migrations
# (newer than all current upgrade steps)
store.set_haiku_version("100.0.0")
store.close()
# Re-open - should update version silently, no error
store = Store(temp_db_path)
# Version should now be current
version = store.get_haiku_version()
expected = metadata.version("haiku.rag-slim")
assert version == expected
store.close()
def test_pending_migrations_raises_error(self, temp_db_path):
"""When actual migrations are pending, should raise MigrationRequiredError."""
store = Store(temp_db_path, create=True)
# Set version to before the first upgrade step
store.set_haiku_version("0.19.0")
store.close()
# Re-open should raise
with pytest.raises(MigrationRequiredError) as exc_info:
Store(temp_db_path)
assert "migrate" in str(exc_info.value).lower()
def test_pending_migrations_read_only_raises_error(self, temp_db_path):
"""Read-only mode with pending migrations should still raise."""
store = Store(temp_db_path, create=True)
store.set_haiku_version("0.19.0")
store.close()
with pytest.raises(MigrationRequiredError):
Store(temp_db_path, read_only=True)
def test_read_only_version_bump_without_migrations_ok(self, temp_db_path):
"""Read-only mode with version bump but no migrations should work."""
store = Store(temp_db_path, create=True)
# Set a version newer than all upgrade steps
store.set_haiku_version("100.0.0")
store.close()
# Read-only open should work (version not updated, but no error)
store = Store(temp_db_path, read_only=True)
# Version should stay at the old value (can't update in read-only)
assert store.get_haiku_version() == "100.0.0"
store.close()
def test_skip_migration_check_bypasses_error(self, temp_db_path):
"""skip_migration_check=True should bypass migration error."""
store = Store(temp_db_path, create=True)
store.set_haiku_version("0.19.0")
store.close()
# Open with skip_migration_check should work
store = Store(temp_db_path, skip_migration_check=True)
# Version should remain old (no auto-migration)
assert store.get_haiku_version() == "0.19.0"
store.close()
class TestMigrateMethod:
def test_migrate_applies_pending_upgrades(self, temp_db_path):
"""Store.migrate() should apply pending upgrades and update version."""
store = Store(temp_db_path, create=True)
store.set_haiku_version("0.19.0")
store.close()
# Open with skip_migration_check to avoid error
store = Store(temp_db_path, skip_migration_check=True)
old_version = store.get_haiku_version()
assert old_version == "0.19.0"
# Run migration
applied = store.migrate()
# Should have applied migrations
assert len(applied) > 0
# Version should be updated
new_version = store.get_haiku_version()
expected = metadata.version("haiku.rag-slim")
assert new_version == expected
store.close()
def test_migrate_returns_applied_upgrades(self, temp_db_path):
"""Store.migrate() should return list of applied upgrade descriptions."""
store = Store(temp_db_path, create=True)
store.set_haiku_version("0.19.0")
store.close()
store = Store(temp_db_path, skip_migration_check=True)
applied = store.migrate()
# Should return descriptions of applied upgrades
assert isinstance(applied, list)
for item in applied:
assert isinstance(item, str)
store.close()
def test_migrate_with_no_pending_returns_empty(self, temp_db_path):
"""Store.migrate() with no pending migrations returns empty list."""
store = Store(temp_db_path, create=True)
# Already at current version
store.close()
store = Store(temp_db_path, skip_migration_check=True)
applied = store.migrate()
assert applied == []
store.close()
def test_migrate_raises_read_only_error(self, temp_db_path):
"""Store.migrate() should raise ReadOnlyError in read-only mode."""
from haiku.rag.store.exceptions import ReadOnlyError
store = Store(temp_db_path, create=True)
store.set_haiku_version("0.19.0")
store.close()
store = Store(temp_db_path, skip_migration_check=True, read_only=True)
with pytest.raises(ReadOnlyError):
store.migrate()
store.close()
class TestGetPendingUpgrades:
def test_get_pending_upgrades_returns_list(self):
"""get_pending_upgrades() should return a list of Upgrade objects."""
from haiku.rag.store.upgrades import get_pending_upgrades
pending = get_pending_upgrades("0.19.0")
assert isinstance(pending, list)
# Should have at least the v0.20.0 upgrade
assert len(pending) > 0
def test_get_pending_upgrades_from_current_version_is_empty(self):
"""get_pending_upgrades() from current version should be empty."""
from haiku.rag.store.upgrades import get_pending_upgrades
current = metadata.version("haiku.rag-slim")
pending = get_pending_upgrades(current)
assert pending == []
def test_get_pending_upgrades_from_future_version_is_empty(self):
"""get_pending_upgrades() from a future version should be empty."""
from haiku.rag.store.upgrades import get_pending_upgrades
pending = get_pending_upgrades("100.0.0")
assert pending == []

View file

@ -602,3 +602,74 @@ async def test_rebuild_empty_database(tmp_path, monkeypatch):
calls = [str(c) for c in mock_print.call_args_list] calls = [str(c) for c in mock_print.call_args_list]
assert any("No documents found" in c for c in calls) assert any("No documents found" in c for c in calls)
def test_migrate_with_pending_migrations(tmp_path):
"""Test migrate method when migrations are applied."""
from haiku.rag.store.engine import Store
db_path = tmp_path / "test.lancedb"
store = Store(db_path, create=True)
store.close()
app = HaikuRAGApp(db_path=db_path)
with patch("haiku.rag.store.engine.Store") as mock_store_class:
mock_store = MagicMock()
mock_store.migrate.return_value = ["Migration 1", "Migration 2"]
mock_store_class.return_value = mock_store
result = app.migrate()
mock_store_class.assert_called_once_with(
db_path,
config=app.config,
skip_validation=True,
skip_migration_check=True,
)
mock_store.migrate.assert_called_once()
mock_store.close.assert_called_once()
assert result == ["Migration 1", "Migration 2"]
def test_migrate_no_pending_migrations(tmp_path):
"""Test migrate method when no migrations are pending."""
from haiku.rag.store.engine import Store
db_path = tmp_path / "test.lancedb"
store = Store(db_path, create=True)
store.close()
app = HaikuRAGApp(db_path=db_path)
with patch("haiku.rag.store.engine.Store") as mock_store_class:
mock_store = MagicMock()
mock_store.migrate.return_value = []
mock_store_class.return_value = mock_store
result = app.migrate()
mock_store.migrate.assert_called_once()
mock_store.close.assert_called_once()
assert result == []
def test_migrate_closes_store_on_exception(tmp_path):
"""Test migrate method closes store even if migration fails."""
from haiku.rag.store.engine import Store
db_path = tmp_path / "test.lancedb"
store = Store(db_path, create=True)
store.close()
app = HaikuRAGApp(db_path=db_path)
with patch("haiku.rag.store.engine.Store") as mock_store_class:
mock_store = MagicMock()
mock_store.migrate.side_effect = Exception("Migration error")
mock_store_class.return_value = mock_store
with pytest.raises(Exception, match="Migration error"):
app.migrate()
mock_store.close.assert_called_once()

View file

@ -2,7 +2,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
from typer.testing import CliRunner from typer.testing import CliRunner
from haiku.rag.cli import cli from haiku.rag.cli import _cli as cli
from haiku.rag.cli import cli as cli_wrapper
from haiku.rag.store.exceptions import MigrationRequiredError
runner = CliRunner() runner = CliRunner()
@ -386,3 +388,63 @@ def test_add_document_src_directory(tmp_path):
mock_app_instance.add_document_from_source.assert_called_once() mock_app_instance.add_document_from_source.assert_called_once()
call_args = mock_app_instance.add_document_from_source.call_args call_args = mock_app_instance.add_document_from_source.call_args
assert call_args[1]["source"] == str(test_dir) assert call_args[1]["source"] == str(test_dir)
def test_migrate_with_applied_migrations():
"""Test migrate command when migrations are applied."""
with patch("haiku.rag.cli.HaikuRAGApp") as mock_app:
mock_app_instance = MagicMock()
mock_app_instance.migrate.return_value = [
"Add full-text search index",
"Add metadata column",
]
mock_app.return_value = mock_app_instance
result = runner.invoke(cli, ["migrate"])
assert result.exit_code == 0
mock_app_instance.migrate.assert_called_once()
assert "Applied 2 migration(s)" in result.output
assert "Add full-text search index" in result.output
assert "Add metadata column" in result.output
assert "Migration completed successfully" in result.output
def test_migrate_no_pending_migrations():
"""Test migrate command when no migrations are pending."""
with patch("haiku.rag.cli.HaikuRAGApp") as mock_app:
mock_app_instance = MagicMock()
mock_app_instance.migrate.return_value = []
mock_app.return_value = mock_app_instance
result = runner.invoke(cli, ["migrate"])
assert result.exit_code == 0
mock_app_instance.migrate.assert_called_once()
assert "No migrations pending" in result.output
assert "Database is up to date" in result.output
def test_migrate_failure():
"""Test migrate command when migration fails."""
with patch("haiku.rag.cli.HaikuRAGApp") as mock_app:
mock_app_instance = MagicMock()
mock_app_instance.migrate.side_effect = Exception("Migration failed")
mock_app.return_value = mock_app_instance
result = runner.invoke(cli, ["migrate"])
assert result.exit_code == 1
assert "Migration failed" in result.output
def test_cli_wrapper_catches_migration_required_error():
"""Test that cli() wrapper catches MigrationRequiredError and exits with code 1."""
with patch("haiku.rag.cli._cli") as mock_cli:
mock_cli.side_effect = MigrationRequiredError(
"Database requires migration. Run 'haiku-rag migrate' to upgrade."
)
with patch("sys.exit") as mock_exit:
cli_wrapper()
mock_exit.assert_called_once_with(1)

View file

@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
from haiku.rag.cli import cli from haiku.rag.cli import _cli as cli
from haiku.rag.store.models import Document from haiku.rag.store.models import Document
runner = CliRunner() runner = CliRunner()

View file

@ -72,20 +72,24 @@ def test_new_database_does_not_run_upgrades(monkeypatch, temp_db_path):
Store(temp_db_path, create=True) Store(temp_db_path, create=True)
def test_existing_database_runs_upgrades(monkeypatch, temp_db_path): def test_existing_database_checks_migrations(monkeypatch, temp_db_path):
Store(temp_db_path, create=True) Store(temp_db_path, create=True)
called = {"value": False} from haiku.rag.store import upgrades
def mark_called(*_args, **_kwargs): called = {"value": False}
original_get_pending = upgrades.get_pending_upgrades
def mark_called(*args, **kwargs):
called["value"] = True called["value"] = True
return original_get_pending(*args, **kwargs)
monkeypatch.setattr( monkeypatch.setattr(
"haiku.rag.store.upgrades.run_pending_upgrades", "haiku.rag.store.upgrades.get_pending_upgrades",
mark_called, mark_called,
) )
# Opening an existing database should trigger upgrades # Opening an existing database should check for pending migrations
Store(temp_db_path) Store(temp_db_path)
assert called["value"] assert called["value"]