fix(session): survive missing FTS5 runtimes
This commit is contained in:
+121
-1
@@ -4,7 +4,7 @@ import sqlite3
|
||||
import time
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state import SCHEMA_SQL, SessionDB
|
||||
|
||||
|
||||
class _NoFtsCursor(sqlite3.Cursor):
|
||||
@@ -12,6 +12,8 @@ class _NoFtsCursor(sqlite3.Cursor):
|
||||
|
||||
def execute(self, sql, parameters=()):
|
||||
probe = sql.strip()
|
||||
if "USING fts5" in probe:
|
||||
raise sqlite3.OperationalError("no such module: fts5")
|
||||
if probe in (
|
||||
"SELECT * FROM messages_fts LIMIT 0",
|
||||
"SELECT * FROM messages_fts_trigram LIMIT 0",
|
||||
@@ -30,6 +32,24 @@ class _NoFtsConnection(sqlite3.Connection):
|
||||
return super().cursor(factory or _NoFtsCursor)
|
||||
|
||||
|
||||
class _NoFtsExistingTableCursor(_NoFtsCursor):
|
||||
"""Simulate existing FTS virtual tables under a runtime without FTS5."""
|
||||
|
||||
def execute(self, sql, parameters=()):
|
||||
probe = sql.strip()
|
||||
if probe in (
|
||||
"SELECT * FROM messages_fts LIMIT 0",
|
||||
"SELECT * FROM messages_fts_trigram LIMIT 0",
|
||||
):
|
||||
raise sqlite3.OperationalError("no such module: fts5")
|
||||
return super().execute(sql, parameters)
|
||||
|
||||
|
||||
class _NoFtsExistingTableConnection(sqlite3.Connection):
|
||||
def cursor(self, factory=None):
|
||||
return super().cursor(factory or _NoFtsExistingTableCursor)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
"""Create a SessionDB with a temp database file."""
|
||||
@@ -210,6 +230,106 @@ class TestSessionLifecycle:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_existing_fts_tables_do_not_break_without_fts5(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
db_path = tmp_path / "state.db"
|
||||
seeded = SessionDB(db_path=db_path)
|
||||
try:
|
||||
seeded.create_session(session_id="s1", source="cli")
|
||||
seeded.append_message("s1", role="user", content="before runtime change")
|
||||
finally:
|
||||
seeded.close()
|
||||
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
def connect_without_fts(*args, **kwargs):
|
||||
kwargs["factory"] = _NoFtsExistingTableConnection
|
||||
return real_connect(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
|
||||
|
||||
db = SessionDB(db_path=db_path)
|
||||
try:
|
||||
assert db._fts_enabled is False
|
||||
assert db.get_session("s1") is not None
|
||||
assert len(db.get_messages("s1")) == 1
|
||||
|
||||
# Existing FTS triggers must be disabled too; otherwise this write
|
||||
# would try to insert into an unusable FTS virtual table.
|
||||
db.append_message("s1", role="assistant", content="after runtime change")
|
||||
messages = db.get_messages("s1")
|
||||
assert len(messages) == 2
|
||||
assert messages[1]["content"] == "after runtime change"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_old_schema_without_fts5_does_not_crash(self, tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "legacy.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
conn.execute("DELETE FROM schema_version")
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", (9,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
def connect_without_fts(*args, **kwargs):
|
||||
kwargs["factory"] = _NoFtsConnection
|
||||
return real_connect(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
|
||||
|
||||
db = SessionDB(db_path=db_path)
|
||||
try:
|
||||
assert db._fts_enabled is False
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="user", content="legacy no fts")
|
||||
assert db.get_messages("s1")[0]["content"] == "legacy no fts"
|
||||
assert db.search_messages("legacy") == []
|
||||
|
||||
# Leave the FTS migration version in place so a future FTS-capable
|
||||
# runtime can still rebuild and backfill the indexes.
|
||||
row = db._conn.execute("SELECT version FROM schema_version").fetchone()
|
||||
assert row["version"] == 9
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_fts_runtime_restores_triggers_after_no_fts_open(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
db_path = tmp_path / "state.db"
|
||||
seeded = SessionDB(db_path=db_path)
|
||||
try:
|
||||
seeded.create_session(session_id="s1", source="cli")
|
||||
seeded.append_message("s1", role="user", content="first searchable")
|
||||
finally:
|
||||
seeded.close()
|
||||
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
def connect_without_fts(*args, **kwargs):
|
||||
kwargs["factory"] = _NoFtsExistingTableConnection
|
||||
return real_connect(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
|
||||
no_fts = SessionDB(db_path=db_path)
|
||||
try:
|
||||
no_fts.append_message("s1", role="assistant", content="not indexed yet")
|
||||
finally:
|
||||
no_fts.close()
|
||||
|
||||
monkeypatch.setattr("hermes_state.sqlite3.connect", real_connect)
|
||||
restored = SessionDB(db_path=db_path)
|
||||
try:
|
||||
assert restored._fts_enabled is True
|
||||
restored.append_message("s1", role="assistant", content="indexed again")
|
||||
assert len(restored.search_messages("not indexed yet")) == 1
|
||||
assert len(restored.search_messages("indexed")) == 2
|
||||
finally:
|
||||
restored.close()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Message storage
|
||||
|
||||
Reference in New Issue
Block a user