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 <crdesign8@hotmail.com>
This commit is contained in:
Teknium
2026-09-02 03:36:51 -07:00
parent d55d9d128a
commit 458e2ef1d2
2 changed files with 59 additions and 104 deletions
+51 -101
View File
@@ -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(
@@ -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()