use rusqlite::Connection;
use crate::error::{Error, Result};
pub const SCHEMA_VERSION: i64 = 5;
const SCHEMA_LATEST: &str = include_str!("schema.sql");
const MIGRATE_1_TO_2: &str = "
CREATE TABLE node_updates (
node_id TEXT NOT NULL,
field TEXT NOT NULL,
value TEXT NOT NULL,
hlc TEXT NOT NULL,
PRIMARY KEY (node_id, field)
) STRICT, WITHOUT ROWID;
";
const MIGRATE_2_TO_3: &str = "
ALTER TABLE episodes ADD COLUMN kind TEXT NOT NULL DEFAULT '';
";
const MIGRATE_3_TO_4: &str = "
CREATE VIRTUAL TABLE nodes_fts_tri USING fts5(
name, summary,
content='nodes', content_rowid='rowid',
tokenize='trigram'
);
CREATE TRIGGER nodes_fts_tri_ai AFTER INSERT ON nodes BEGIN
INSERT INTO nodes_fts_tri(rowid, name, summary)
VALUES (new.rowid, new.name, new.summary);
END;
CREATE TRIGGER nodes_fts_tri_ad AFTER DELETE ON nodes BEGIN
INSERT INTO nodes_fts_tri(nodes_fts_tri, rowid, name, summary)
VALUES ('delete', old.rowid, old.name, old.summary);
END;
CREATE TRIGGER nodes_fts_tri_au AFTER UPDATE OF name, summary ON nodes BEGIN
INSERT INTO nodes_fts_tri(nodes_fts_tri, rowid, name, summary)
VALUES ('delete', old.rowid, old.name, old.summary);
INSERT INTO nodes_fts_tri(rowid, name, summary)
VALUES (new.rowid, new.name, new.summary);
END;
CREATE VIRTUAL TABLE edges_fts_tri USING fts5(
fact,
content='edges', content_rowid='rowid',
tokenize='trigram'
);
CREATE TRIGGER edges_fts_tri_ai AFTER INSERT ON edges BEGIN
INSERT INTO edges_fts_tri(rowid, fact) VALUES (new.rowid, new.fact);
END;
CREATE TRIGGER edges_fts_tri_ad AFTER DELETE ON edges BEGIN
INSERT INTO edges_fts_tri(edges_fts_tri, rowid, fact)
VALUES ('delete', old.rowid, old.fact);
END;
CREATE TRIGGER edges_fts_tri_au AFTER UPDATE OF fact ON edges BEGIN
INSERT INTO edges_fts_tri(edges_fts_tri, rowid, fact)
VALUES ('delete', old.rowid, old.fact);
INSERT INTO edges_fts_tri(rowid, fact) VALUES (new.rowid, new.fact);
END;
CREATE VIRTUAL TABLE episodes_fts_tri USING fts5(
content,
content='episodes', content_rowid='rowid',
tokenize='trigram'
);
CREATE TRIGGER episodes_fts_tri_ai AFTER INSERT ON episodes BEGIN
INSERT INTO episodes_fts_tri(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER episodes_fts_tri_ad AFTER DELETE ON episodes BEGIN
INSERT INTO episodes_fts_tri(episodes_fts_tri, rowid, content)
VALUES ('delete', old.rowid, old.content);
END;
CREATE TRIGGER episodes_fts_tri_au AFTER UPDATE OF content ON episodes BEGIN
INSERT INTO episodes_fts_tri(episodes_fts_tri, rowid, content)
VALUES ('delete', old.rowid, old.content);
INSERT INTO episodes_fts_tri(rowid, content) VALUES (new.rowid, new.content);
END;
INSERT INTO nodes_fts_tri(nodes_fts_tri) VALUES ('rebuild');
INSERT INTO edges_fts_tri(edges_fts_tri) VALUES ('rebuild');
INSERT INTO episodes_fts_tri(episodes_fts_tri) VALUES ('rebuild');
";
fn migrate_4_to_5(conn: &mut Connection) -> Result<()> {
let tx = conn.transaction()?;
let dim: Option<i64> = tx
.query_row("SELECT dim FROM embedding_meta WHERE id = 1", [], |r| {
r.get(0)
})
.map(Some)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other),
})?;
for (vec_table, base) in [("vec_nodes", "nodes"), ("vec_edges", "edges")] {
let exists: i64 = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name = ?1",
rusqlite::params![vec_table],
|r| r.get(0),
)?;
if exists == 0 {
continue;
}
let Some(dim) = dim else { continue };
tx.execute_batch(&format!(
"CREATE TABLE vec_migrate_stash (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
embedding BLOB NOT NULL
) STRICT;
INSERT INTO vec_migrate_stash (id, group_id, embedding)
SELECT v.id, b.group_id, v.embedding
FROM {vec_table} AS v JOIN {base} AS b ON b.id = v.id;
DROP TABLE {vec_table};
CREATE VIRTUAL TABLE {vec_table} USING vec0(
id TEXT PRIMARY KEY, group_id TEXT PARTITION KEY,
embedding FLOAT[{dim}] distance_metric=cosine);
INSERT INTO {vec_table} (id, group_id, embedding)
SELECT id, group_id, embedding FROM vec_migrate_stash;
DROP TABLE vec_migrate_stash;"
))?;
}
tx.pragma_update(None, "user_version", 5)?;
tx.commit()?;
Ok(())
}
pub(crate) fn migrate(conn: &mut Connection) -> Result<()> {
loop {
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
match version {
SCHEMA_VERSION => return Ok(()),
v if v > SCHEMA_VERSION => {
return Err(Error::SchemaTooNew {
found: v,
supported: SCHEMA_VERSION,
});
}
0 => apply_step(conn, SCHEMA_LATEST, SCHEMA_VERSION)?,
1 => apply_step(conn, MIGRATE_1_TO_2, 2)?,
2 => apply_step(conn, MIGRATE_2_TO_3, 3)?,
3 => apply_step(conn, MIGRATE_3_TO_4, 4)?,
4 => migrate_4_to_5(conn)?,
v => unreachable!("no migration path from version {v}"),
}
}
}
fn apply_step(conn: &mut Connection, sql: &str, target: i64) -> Result<()> {
let tx = conn.transaction()?;
tx.execute_batch(sql)?;
tx.pragma_update(None, "user_version", target)?;
tx.commit()?;
Ok(())
}