From 8a06a9d96ab1d2fb10e550ba397effa6dd67108f Mon Sep 17 00:00:00 2001 From: Wu Shuwen Date: Fri, 16 Oct 2026 06:31:09 +0800 Subject: [PATCH 1/2] fix: report unreadable caches from search API routes --- claude_code_log/api.py | 29 ++++++++++++++++++++++------- test/test_api.py | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/claude_code_log/api.py b/claude_code_log/api.py index 6db8ca60..25f9d31b 100644 --- a/claude_code_log/api.py +++ b/claude_code_log/api.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import Any, Callable, Optional, Sequence -from .cache import get_library_version +from .cache import get_library_version, is_corrupt_database_error from .search import ( DEFAULT_SEARCH_FIELDS, SEARCH_FIELDS, @@ -89,11 +89,20 @@ def ping(self, _params: dict[str, str]) -> dict[str, Any]: def projects(self, _params: dict[str, str]) -> dict[str, Any]: """Project list for the filter dropdown, newest activity first.""" - rows = self.connection().execute( - "SELECT id, project_path, total_message_count, earliest_timestamp, " - " latest_timestamp " - " FROM projects ORDER BY latest_timestamp DESC" - ) + try: + rows = ( + self.connection() + .execute( + "SELECT id, project_path, total_message_count, earliest_timestamp, " + " latest_timestamp " + " FROM projects ORDER BY latest_timestamp DESC" + ) + .fetchall() + ) + except sqlite3.DatabaseError as exc: + if not is_corrupt_database_error(exc): + raise + raise ValueError(f"cache database is unreadable ({exc})") from exc return { "projects": [ { @@ -127,7 +136,13 @@ def search(self, params: dict[str, str]) -> dict[str, Any]: # Cheap readiness check, not full `index_status`: that one reports # counts, and even the aggregate version is more work than a search # needs to do on every keystroke. - if not index_ready(conn): + try: + ready = index_ready(conn) + except sqlite3.DatabaseError as exc: + if not is_corrupt_database_error(exc): + raise + ready = False + if not ready: status = index_status(conn) raise ValueError( f"search index is not available ({status.reason or 'not built'})" diff --git a/test/test_api.py b/test/test_api.py index 824a2267..cae7e9e8 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -277,6 +277,28 @@ def test_search_without_an_index_is_a_clean_error(tmp_path: Path) -> None: assert "not available" in json.loads(exc.read())["error"] +@pytest.mark.parametrize("endpoint", ["/api/search?q=anything", "/api/projects"]) +def test_corrupt_cache_is_a_clean_error(tmp_path: Path, endpoint: str) -> None: + path = tmp_path / "cache.db" + path.write_bytes(b"not a SQLite database") + api = SearchApi(path) + with ArchiveServer(tmp_path, api_routes=api.routes(), port=0) as server: + with pytest.raises(urllib.error.HTTPError) as error: + urllib.request.urlopen(f"{server.url}{endpoint}") + assert error.value.code == 400 + message = json.loads(error.value.read())["error"] + assert "cache database is unreadable" in message + assert "DatabaseError(" not in message + + +def test_projects_does_not_hide_other_database_errors(tmp_path: Path) -> None: + path = tmp_path / "cache.db" + sqlite3.connect(path).close() + api = SearchApi(path) + with pytest.raises(sqlite3.OperationalError, match="no such table: projects"): + api.projects({}) + + # --- the performance contract -------------------------------------------- From 984a8781a12f9a6944a0f81a1c00dce4a3e85b1c Mon Sep 17 00:00:00 2001 From: Wu Shuwen Date: Fri, 6 Nov 2026 02:20:07 +0800 Subject: [PATCH 2/2] Handle corrupted cache pages across API routes --- claude_code_log/api.py | 16 +++++++++++++++- test/test_api.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/claude_code_log/api.py b/claude_code_log/api.py index 25f9d31b..e16fbcc5 100644 --- a/claude_code_log/api.py +++ b/claude_code_log/api.py @@ -206,8 +206,22 @@ def search(self, params: dict[str, str]) -> dict[str, Any]: } def routes(self) -> dict[str, Callable[[dict[str, str]], Any]]: - return { + def clean_corruption( + handler: Callable[[dict[str, str]], Any], + ) -> Callable[[dict[str, str]], Any]: + def wrapped(params: dict[str, str]) -> Any: + try: + return handler(params) + except sqlite3.DatabaseError as exc: + if not is_corrupt_database_error(exc): + raise + raise ValueError(f"cache database is unreadable ({exc})") from exc + + return wrapped + + routes = { "/api/ping": self.ping, "/api/search": self.search, "/api/projects": self.projects, } + return {path: clean_corruption(handler) for path, handler in routes.items()} diff --git a/test/test_api.py b/test/test_api.py index cae7e9e8..46c85d8d 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -299,6 +299,37 @@ def test_projects_does_not_hide_other_database_errors(tmp_path: Path) -> None: api.projects({}) +def test_damaged_fts_page_is_a_clean_search_error( + db_path: Path, tmp_path: Path +) -> None: + with sqlite3.connect(db_path) as conn: + page_size = conn.execute("PRAGMA page_size").fetchone()[0] + root_page = conn.execute( + "SELECT rootpage FROM sqlite_master WHERE name = 'message_fts_data'" + ).fetchone()[0] + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + with db_path.open("r+b") as db: + db.seek((root_page - 1) * page_size) + db.write(b"\x00" * page_size) + + api = SearchApi(db_path) + with ArchiveServer(tmp_path, api_routes=api.routes(), port=0) as server: + with pytest.raises(urllib.error.HTTPError) as error: + urllib.request.urlopen(f"{server.url}/api/search?q=pydantic") + assert error.value.code == 400 + message = json.loads(error.value.read())["error"] + assert "cache database is unreadable" in message + assert "DatabaseError(" not in message + + +def test_route_does_not_hide_other_database_errors(tmp_path: Path) -> None: + path = tmp_path / "cache.db" + sqlite3.connect(path).close() + handler = SearchApi(path).routes()["/api/projects"] + with pytest.raises(sqlite3.OperationalError, match="no such table: projects"): + handler({}) + + # --- the performance contract --------------------------------------------