use anyhow::{anyhow, bail, Result};
use rusqlite::Connection;
const MIN_GENERATED_COLUMN: (u32, u32) = (3, 31);
pub(crate) fn migrate_v12_chat_columns_fts(tx: &rusqlite::Transaction<'_>) -> Result<()> {
ensure_sqlite_supports_generated_columns(tx)?;
tx.execute_batch(
r#"
ALTER TABLE graph_entities ADD COLUMN session_id TEXT
GENERATED ALWAYS AS (json_extract(data, '$.session_id')) VIRTUAL;
ALTER TABLE graph_entities ADD COLUMN sequence INTEGER
GENERATED ALWAYS AS (json_extract(data, '$.sequence')) VIRTUAL;
ALTER TABLE graph_entities ADD COLUMN role TEXT
GENERATED ALWAYS AS (json_extract(data, '$.role')) VIRTUAL;
ALTER TABLE graph_entities ADD COLUMN content_text TEXT
GENERATED ALWAYS AS (
coalesce(json_extract(data, '$.content_summary'), '') || ' ' ||
coalesce(json_extract(data, '$.content'), '') || ' ' ||
coalesce(json_extract(data, '$.tool_name'), '') || ' ' ||
coalesce(json_extract(data, '$.tool_category'), '')
) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_entities_session_seq
ON graph_entities(session_id, sequence);
CREATE INDEX IF NOT EXISTS idx_entities_session_role_seq
ON graph_entities(session_id, role, sequence);
-- FTS5 over chat-turn content. External-content table = graph_entities;
-- content lives in the FTS index, kept in sync by the triggers below.
-- Only chat kinds are indexed (keeps the index small).
CREATE VIRTUAL TABLE IF NOT EXISTS entity_fts USING fts5(
content_text,
content='graph_entities', content_rowid='id',
tokenize='porter unicode61'
);
-- Backfill existing chat rows.
INSERT INTO entity_fts(rowid, content_text)
SELECT id, content_text FROM graph_entities
WHERE kind IN ('ReasoningLog', 'ToolCall');
-- Sync triggers (FTS5 external content does not auto-sync). The insert
-- and delete triggers fire only for chat kinds. The update path is split
-- into two triggers so a kind transition is handled correctly: remove
-- the old entry iff the old row was chat, add the new entry iff the new
-- row is chat. (atheneum never transitions a row's kind in practice, but
-- the split makes the invariant explicit rather than relying on that.)
CREATE TRIGGER IF NOT EXISTS entity_fts_ai AFTER INSERT ON graph_entities
WHEN new.kind IN ('ReasoningLog', 'ToolCall')
BEGIN
INSERT INTO entity_fts(rowid, content_text)
VALUES (new.id, new.content_text);
END;
CREATE TRIGGER IF NOT EXISTS entity_fts_ad AFTER DELETE ON graph_entities
WHEN old.kind IN ('ReasoningLog', 'ToolCall')
BEGIN
INSERT INTO entity_fts(entity_fts, rowid, content_text)
VALUES ('delete', old.id, old.content_text);
END;
CREATE TRIGGER IF NOT EXISTS entity_fts_au_old AFTER UPDATE ON graph_entities
WHEN old.kind IN ('ReasoningLog', 'ToolCall')
BEGIN
INSERT INTO entity_fts(entity_fts, rowid, content_text)
VALUES ('delete', old.id, old.content_text);
END;
CREATE TRIGGER IF NOT EXISTS entity_fts_au_new AFTER UPDATE ON graph_entities
WHEN new.kind IN ('ReasoningLog', 'ToolCall')
BEGIN
INSERT INTO entity_fts(rowid, content_text)
VALUES (new.id, new.content_text);
END;
"#,
)?;
Ok(())
}
fn ensure_sqlite_supports_generated_columns(conn: &Connection) -> Result<()> {
let version: String = conn.query_row("SELECT sqlite_version()", [], |row| row.get(0))?;
let mut parts = version.split('.');
let major = parts.next().and_then(|s| s.parse::<u32>().ok());
let minor = parts.next().and_then(|s| s.parse::<u32>().ok());
match (major, minor) {
(Some(maj), Some(min)) if (maj, min) >= MIN_GENERATED_COLUMN => Ok(()),
(Some(maj), Some(min)) => bail!(
"atheneum migration v12 needs SQLite >= {}.{} for generated columns; \
this database reports {} ({}.{}). Upgrade the system SQLite, or enable \
the rusqlite 'bundled' feature in atheneum's Cargo.toml.",
MIN_GENERATED_COLUMN.0,
MIN_GENERATED_COLUMN.1,
version,
maj,
min
),
_ => Err(anyhow!("cannot parse sqlite_version() output: {}", version)),
}
}