From 458e2ef1d2f1058f91ea9d85ec1cf5c28216fa76 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:36:51 -0700 Subject: [PATCH] refactor(state): collapse telegram topic v3 migration into one table-driven rebuild Same behavior as the salvaged #76487 migration (fresh installs get the v3 shape; v1/v2 tables rebuild with profile_name leading the PK, legacy rows into 'default' only, CASCADE FK supplied on the way), with the per-table DDL written once instead of three times and the now-redundant v1->v2 CASCADE-only rebuild dropped (the v3 rebuild subsumes it). Co-authored-by: Celio Monteiro --- hermes_state.py | 152 ++++++------------ ..._telegram_topic_profile_isolation_76423.py | 11 +- 2 files changed, 59 insertions(+), 104 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 091396533d..88c6bd59f3 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -16111,20 +16111,16 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) gateways (shared ``state.db``) isolate topic mode/bindings per Hermes profile (issue #76423). """ - def _table_columns(conn, table: str) -> set: - try: - return { - row[1] - for row in conn.execute(f"PRAGMA table_info('{table}')").fetchall() - } - except sqlite3.OperationalError: - return set() - - def _do(conn): - # Fresh installs get the v3 shape immediately. - conn.executescript( + # (table, column list, DDL body). ``profile_name`` leads the primary + # key so multiplexed profiles sharing one state.db never collide on a + # private chat_id (which is the user id, identical across bots). + tables = ( + ( + "telegram_dm_topic_mode", + "profile_name, chat_id, user_id, enabled, activated_at, updated_at, " + "has_topics_enabled, allows_users_to_create_topics, " + "capability_checked_at, intro_message_id, pinned_message_id", """ - CREATE TABLE IF NOT EXISTS telegram_dm_topic_mode ( profile_name TEXT NOT NULL DEFAULT 'default', chat_id TEXT NOT NULL, user_id TEXT NOT NULL, @@ -16137,9 +16133,13 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) intro_message_id TEXT, pinned_message_id TEXT, PRIMARY KEY (profile_name, chat_id) - ); - - CREATE TABLE IF NOT EXISTS telegram_dm_topic_bindings ( + """, + ), + ( + "telegram_dm_topic_bindings", + "profile_name, chat_id, thread_id, user_id, session_key, " + "session_id, managed_mode, linked_at, updated_at", + """ profile_name TEXT NOT NULL DEFAULT 'default', chat_id TEXT NOT NULL, thread_id TEXT NOT NULL, @@ -16150,93 +16150,43 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) linked_at REAL NOT NULL, updated_at REAL NOT NULL, PRIMARY KEY (profile_name, chat_id, thread_id) - ); + """, + ), + ) + + def _do(conn): + for table, columns, ddl in tables: + # Fresh installs get the v3 shape immediately. + conn.execute(f"CREATE TABLE IF NOT EXISTS {table} ({ddl})") + have = {row[1] for row in conn.execute(f"PRAGMA table_info('{table}')")} + if "profile_name" in have: + continue + # Pre-profile shape (v1 or v2) → v3. SQLite can't ALTER a + # primary key (or a foreign key), so rebuild; this also + # supplies the v2 ON DELETE CASCADE for v1 bindings tables. + # Legacy rows land in the "default" namespace only — never + # replicated across profiles. + legacy_columns = columns.replace("profile_name, ", "", 1) + conn.executescript( + f""" + CREATE TABLE {table}_new ({ddl}); + INSERT INTO {table}_new ({columns}) + SELECT 'default', {legacy_columns} FROM {table}; + DROP TABLE {table}; + ALTER TABLE {table}_new RENAME TO {table}; + """ + ) + + # Indexes after any rebuild so they always target the v3 shape + # (a legacy table lacking profile_name can't take the user index). + conn.executescript( """ - ) - # Indexes are created after any rebuild: a legacy table still - # lacking profile_name cannot accept the v3 user index. + CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_session + ON telegram_dm_topic_bindings(session_id); - mode_cols = _table_columns(conn, "telegram_dm_topic_mode") - bind_cols = _table_columns(conn, "telegram_dm_topic_bindings") - - # Any pre-profile shape (v1 or v2) → v3: rebuild both tables with - # profile_name in the primary key. SQLite can't ALTER a primary key - # (or a foreign key), so we rebuild; the bindings rebuild also - # supplies the v2 ON DELETE CASCADE for v1 tables. Legacy rows - # migrate into the "default" namespace only — never replicated - # across profiles (collision-contaminated data would multiply). - if "profile_name" not in mode_cols: - conn.executescript( - """ - CREATE TABLE telegram_dm_topic_mode_new ( - profile_name TEXT NOT NULL DEFAULT 'default', - chat_id TEXT NOT NULL, - user_id TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - activated_at REAL NOT NULL, - updated_at REAL NOT NULL, - has_topics_enabled INTEGER, - allows_users_to_create_topics INTEGER, - capability_checked_at REAL, - intro_message_id TEXT, - pinned_message_id TEXT, - PRIMARY KEY (profile_name, chat_id) - ); - INSERT INTO telegram_dm_topic_mode_new ( - profile_name, chat_id, user_id, enabled, activated_at, - updated_at, has_topics_enabled, allows_users_to_create_topics, - capability_checked_at, intro_message_id, pinned_message_id - ) - SELECT - 'default', chat_id, user_id, enabled, activated_at, - updated_at, has_topics_enabled, allows_users_to_create_topics, - capability_checked_at, intro_message_id, pinned_message_id - FROM telegram_dm_topic_mode; - DROP TABLE telegram_dm_topic_mode; - ALTER TABLE telegram_dm_topic_mode_new - RENAME TO telegram_dm_topic_mode; - """ - ) - - if "profile_name" not in bind_cols: - conn.executescript( - """ - CREATE TABLE telegram_dm_topic_bindings_new ( - profile_name TEXT NOT NULL DEFAULT 'default', - chat_id TEXT NOT NULL, - thread_id TEXT NOT NULL, - user_id TEXT NOT NULL, - session_key TEXT NOT NULL, - session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - managed_mode TEXT NOT NULL DEFAULT 'auto', - linked_at REAL NOT NULL, - updated_at REAL NOT NULL, - PRIMARY KEY (profile_name, chat_id, thread_id) - ); - INSERT INTO telegram_dm_topic_bindings_new ( - profile_name, chat_id, thread_id, user_id, session_key, - session_id, managed_mode, linked_at, updated_at - ) - SELECT - 'default', chat_id, thread_id, user_id, session_key, - session_id, managed_mode, linked_at, updated_at - FROM telegram_dm_topic_bindings; - DROP TABLE telegram_dm_topic_bindings; - ALTER TABLE telegram_dm_topic_bindings_new - RENAME TO telegram_dm_topic_bindings; - """ - ) - - # Indexes after any rebuild so they always target the v3 shape. - conn.execute( - "CREATE UNIQUE INDEX IF NOT EXISTS " - "idx_telegram_dm_topic_bindings_session " - "ON telegram_dm_topic_bindings(session_id)" - ) - conn.execute( - "CREATE INDEX IF NOT EXISTS " - "idx_telegram_dm_topic_bindings_user " - "ON telegram_dm_topic_bindings(profile_name, user_id, chat_id)" + CREATE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_user + ON telegram_dm_topic_bindings(profile_name, user_id, chat_id); + """ ) conn.execute( diff --git a/tests/gateway/test_telegram_topic_profile_isolation_76423.py b/tests/gateway/test_telegram_topic_profile_isolation_76423.py index e4c22aea7e..32d8323335 100644 --- a/tests/gateway/test_telegram_topic_profile_isolation_76423.py +++ b/tests/gateway/test_telegram_topic_profile_isolation_76423.py @@ -15,13 +15,14 @@ def _session(db, sid, profile_name=None): db.create_session(session_id=sid, source="telegram", user_id=CHAT, profile_name=profile_name) -def test_legacy_v2_rows_migrate_only_to_default(tmp_path: Path): +def test_legacy_rows_migrate_only_to_default(tmp_path: Path): + """v1 shape (no CASCADE, old user index) → v3: rows land in 'default' only.""" db_path = tmp_path / "legacy.db" conn = sqlite3.connect(str(db_path)) conn.executescript( f""" CREATE TABLE state_meta (key TEXT PRIMARY KEY, value TEXT); - INSERT INTO state_meta(key, value) VALUES ('telegram_dm_topic_schema_version', '2'); + INSERT INTO state_meta(key, value) VALUES ('telegram_dm_topic_schema_version', '1'); CREATE TABLE sessions ( id TEXT PRIMARY KEY, source TEXT, user_id TEXT, model TEXT, model_config TEXT, system_prompt TEXT, parent_session_id TEXT, @@ -43,11 +44,13 @@ def test_legacy_v2_rows_migrate_only_to_default(tmp_path: Path): CREATE TABLE telegram_dm_topic_bindings ( chat_id TEXT NOT NULL, thread_id TEXT NOT NULL, user_id TEXT NOT NULL, session_key TEXT NOT NULL, - session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES sessions(id), managed_mode TEXT NOT NULL DEFAULT 'auto', linked_at REAL NOT NULL, updated_at REAL NOT NULL, PRIMARY KEY (chat_id, thread_id) ); + CREATE INDEX idx_telegram_dm_topic_bindings_user + ON telegram_dm_topic_bindings(user_id, chat_id); INSERT INTO telegram_dm_topic_bindings VALUES ('{CHAT}', '99', '{CHAT}', 'k', 'legacy-sess', 'auto', 1.0, 1.0); """ @@ -69,6 +72,8 @@ def test_legacy_v2_rows_migrate_only_to_default(tmp_path: Path): assert db.get_telegram_topic_binding( chat_id=CHAT, thread_id="99", profile_name="coder", ) is None + fk = db._conn.execute("PRAGMA foreign_key_list('telegram_dm_topic_bindings')").fetchall() + assert any(row[2] == "sessions" and row[6] == "CASCADE" for row in fk) db.close()