Skip to main content

llm_kernel/graph/
schema.rs

1//! Schema initialization for the knowledge graph SQLite database.
2
3use rusqlite::{Connection, params};
4
5use crate::error::{KernelError, Result};
6
7/// Current graph schema version. Increment when adding migrations.
8pub const GRAPH_SCHEMA_VERSION: u32 = 4;
9
10/// Read the recorded graph schema version from `_meta`, or `0` if unset.
11pub fn schema_version(conn: &Connection) -> Result<u32> {
12    Ok(conn
13        .query_row(
14            "SELECT value FROM _meta WHERE key = 'graph_schema_version'",
15            [],
16            |row| row.get::<_, String>(0),
17        )
18        .ok()
19        .and_then(|s| s.parse().ok())
20        .unwrap_or(0))
21}
22
23/// Incremental migration for the knowledge graph schema.
24///
25/// Applies version-to-version migrations from `current` up to
26/// [`GRAPH_SCHEMA_VERSION`] inside a single transaction. On failure the
27/// transaction rolls back and the recorded version is unchanged. Returns the
28/// new version (equal to `current` when already up to date).
29pub fn migrate_graph(conn: &Connection, current: u32) -> Result<u32> {
30    if current >= GRAPH_SCHEMA_VERSION {
31        return Ok(current);
32    }
33    let tx = conn
34        .unchecked_transaction()
35        .map_err(|e| KernelError::Store(format!("migration begin failed: {e}")))?;
36    let mut v = current;
37    // v1 -> v2: index nodes by creation timestamp (used by recency ordering).
38    if v < 2 {
39        tx.execute_batch("CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(created);")
40            .map_err(|e| KernelError::Store(format!("migration v1->v2 failed: {e}")))?;
41        v = 2;
42    }
43    // v2 -> v3: composite indexes for relation-filtered directed edge lookups
44    // (powers edges_for_node_dir / neighbors_weighted with a relation filter).
45    if v < 3 {
46        tx.execute_batch(
47            "CREATE INDEX IF NOT EXISTS idx_edges_src_rel ON edges(source, relation);
48             CREATE INDEX IF NOT EXISTS idx_edges_tgt_rel ON edges(target, relation);",
49        )
50        .map_err(|e| KernelError::Store(format!("migration v2->v3 failed: {e}")))?;
51        v = 3;
52    }
53    // v3 -> v4: temporal validity columns (empty = unset / never verified).
54    // Skipped when the columns already exist — `init_graph_schema` also adds
55    // them idempotently, so an in-place-upgraded DB may reach `migrate_graph`
56    // with the columns present but `_meta` still at v3.
57    if v < 4 {
58        if !has_column(&tx, "valid_until") {
59            tx.execute_batch("ALTER TABLE nodes ADD COLUMN valid_until TEXT NOT NULL DEFAULT '';")
60                .map_err(|e| KernelError::Store(format!("migration v3->v4 failed: {e}")))?;
61        }
62        if !has_column(&tx, "last_verified") {
63            tx.execute_batch(
64                "ALTER TABLE nodes ADD COLUMN last_verified TEXT NOT NULL DEFAULT '';",
65            )
66            .map_err(|e| KernelError::Store(format!("migration v3->v4 failed: {e}")))?;
67        }
68        v = 4;
69    }
70    tx.execute(
71        "UPDATE _meta SET value = ?1 WHERE key = 'graph_schema_version'",
72        params![v.to_string()],
73    )
74    .map_err(|e| KernelError::Store(e.to_string()))?;
75    tx.commit()
76        .map_err(|e| KernelError::Store(format!("migration commit failed: {e}")))?;
77    Ok(v)
78}
79
80/// True when `nodes` already has a column named `col`.
81fn has_column(conn: &Connection, col: &str) -> bool {
82    let Ok(mut stmt) = conn.prepare("PRAGMA table_info(nodes)") else {
83        return false;
84    };
85    let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(1)) else {
86        return false;
87    };
88    rows.flatten().any(|c| c == col)
89}
90
91/// Apply the full knowledge graph schema (tables, indexes, FTS5 triggers) to a connection.
92///
93/// Idempotent — uses `IF NOT EXISTS` for all DDL. Safe to call on every startup.
94pub fn init_graph_schema(conn: &Connection) -> Result<()> {
95    // WAL auto-checkpoint for better concurrency
96    let _ = conn.execute_batch("PRAGMA wal_autocheckpoint=100;");
97
98    conn.execute_batch(
99        "CREATE TABLE IF NOT EXISTS nodes (
100            id           TEXT PRIMARY KEY,
101            type         TEXT NOT NULL,
102            title        TEXT NOT NULL,
103            tags         TEXT NOT NULL DEFAULT '',
104            projects     TEXT NOT NULL DEFAULT '',
105            agents       TEXT NOT NULL DEFAULT '',
106            created      TEXT NOT NULL,
107            updated      TEXT NOT NULL,
108            body         TEXT NOT NULL DEFAULT '',
109            importance   REAL NOT NULL DEFAULT 0.5,
110            access_count INTEGER NOT NULL DEFAULT 0,
111            accessed_at  TEXT NOT NULL DEFAULT '',
112            valid_until  TEXT NOT NULL DEFAULT '',
113            last_verified TEXT NOT NULL DEFAULT ''
114        );
115
116        -- FTS5 full-text search with trigram tokenizer
117        CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts
118            USING fts5(title, body, tags, content=nodes, content_rowid=rowid, tokenize='trigram');
119
120        -- Keep FTS in sync with node changes
121        CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN
122            INSERT INTO nodes_fts(rowid, title, body, tags)
123            VALUES (new.rowid, new.title, new.body, new.tags);
124        END;
125        CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN
126            INSERT INTO nodes_fts(nodes_fts, rowid, title, body, tags)
127            VALUES('delete', old.rowid, old.title, old.body, old.tags);
128        END;
129        CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN
130            INSERT INTO nodes_fts(nodes_fts, rowid, title, body, tags)
131            VALUES('delete', old.rowid, old.title, old.body, old.tags);
132            INSERT INTO nodes_fts(rowid, title, body, tags)
133            VALUES (new.rowid, new.title, new.body, new.tags);
134        END;
135
136        CREATE TABLE IF NOT EXISTS edges (
137            id       TEXT PRIMARY KEY,
138            source   TEXT NOT NULL,
139            target   TEXT NOT NULL,
140            relation TEXT NOT NULL DEFAULT 'related',
141            weight   REAL NOT NULL DEFAULT 1.0,
142            ts       TEXT NOT NULL
143        );
144
145        CREATE INDEX IF NOT EXISTS idx_edges_source  ON edges(source);
146        CREATE INDEX IF NOT EXISTS idx_edges_target  ON edges(target);
147        CREATE UNIQUE INDEX IF NOT EXISTS idx_edges_src_tgt_rel ON edges(source, target, relation);
148        CREATE INDEX IF NOT EXISTS idx_edges_src_rel ON edges(source, relation);
149        CREATE INDEX IF NOT EXISTS idx_edges_tgt_rel ON edges(target, relation);
150        CREATE INDEX IF NOT EXISTS idx_nodes_type    ON nodes(type);
151        CREATE INDEX IF NOT EXISTS idx_nodes_updated ON nodes(updated DESC);
152        CREATE INDEX IF NOT EXISTS idx_nodes_title_updated ON nodes(title, updated DESC);
153        CREATE INDEX IF NOT EXISTS idx_nodes_importance ON nodes(importance DESC);
154        CREATE INDEX IF NOT EXISTS idx_nodes_accessed ON nodes(accessed_at DESC);
155        CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(created);
156
157        -- Schema version tracking
158        CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
159        INSERT OR IGNORE INTO _meta (key, value) VALUES ('graph_schema_version', '4');
160        ",
161    )
162    .map_err(|e| KernelError::Store(format!("Graph schema init failed: {}", e)))?;
163
164    // v4 columns: `CREATE TABLE IF NOT EXISTS` does not add columns to an
165    // existing table, so upgrade in-place. Tolerates "duplicate column name"
166    // when the columns already exist (idempotent re-init).
167    for col in ["valid_until", "last_verified"] {
168        let _ = conn.execute_batch(&format!(
169            "ALTER TABLE nodes ADD COLUMN {col} TEXT NOT NULL DEFAULT '';"
170        ));
171    }
172
173    Ok(())
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn mem_db() -> Connection {
181        let conn = Connection::open_in_memory().unwrap();
182        init_graph_schema(&conn).unwrap();
183        conn
184    }
185
186    #[test]
187    fn schema_creates_tables() {
188        let conn = mem_db();
189        let tables: Vec<String> = conn
190            .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
191            .unwrap()
192            .query_map([], |r| r.get(0))
193            .unwrap()
194            .flatten()
195            .collect();
196        assert!(tables.contains(&"nodes".to_string()));
197        assert!(tables.contains(&"edges".to_string()));
198        assert!(tables.contains(&"_meta".to_string()));
199    }
200
201    #[test]
202    fn schema_is_idempotent() {
203        let conn = Connection::open_in_memory().unwrap();
204        init_graph_schema(&conn).unwrap();
205        init_graph_schema(&conn).unwrap();
206        let count: i64 = conn
207            .query_row(
208                "SELECT COUNT(*) FROM _meta WHERE key = 'graph_schema_version'",
209                [],
210                |r| r.get(0),
211            )
212            .unwrap();
213        assert_eq!(count, 1);
214    }
215
216    #[test]
217    fn fts_table_exists() {
218        let conn = mem_db();
219        let name: String = conn
220            .query_row(
221                "SELECT name FROM sqlite_master WHERE type='table' AND name='nodes_fts'",
222                [],
223                |r| r.get(0),
224            )
225            .unwrap();
226        assert_eq!(name, "nodes_fts");
227    }
228
229    /// Helper: force the recorded version down to `v` (simulates an older DB).
230    fn set_version(conn: &Connection, v: u32) {
231        conn.execute(
232            "UPDATE _meta SET value = ?1 WHERE key = 'graph_schema_version'",
233            params![v.to_string()],
234        )
235        .unwrap();
236    }
237
238    fn has_index(conn: &Connection, name: &str) -> bool {
239        let count: i64 = conn
240            .query_row(
241                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name = ?1",
242                params![name],
243                |r| r.get(0),
244            )
245            .unwrap();
246        count > 0
247    }
248
249    #[test]
250    fn schema_version_reads_meta() {
251        let conn = mem_db();
252        assert_eq!(schema_version(&conn).unwrap(), GRAPH_SCHEMA_VERSION);
253        set_version(&conn, 1);
254        assert_eq!(schema_version(&conn).unwrap(), 1);
255    }
256
257    /// AC5: a v1 database migrates up to the current version, applying the
258    /// v1→v2 step (the `idx_nodes_created` index becomes observable).
259    #[test]
260    fn migrate_advances_v1_to_current() {
261        let conn = mem_db();
262        // Drop the v2 index, then rewind the version to simulate a v1 DB.
263        conn.execute_batch("DROP INDEX IF EXISTS idx_nodes_created;")
264            .unwrap();
265        set_version(&conn, 1);
266        assert!(!has_index(&conn, "idx_nodes_created"));
267
268        let new_version = migrate_graph(&conn, 1).unwrap();
269        assert_eq!(new_version, GRAPH_SCHEMA_VERSION);
270        assert_eq!(schema_version(&conn).unwrap(), GRAPH_SCHEMA_VERSION);
271        assert!(has_index(&conn, "idx_nodes_created"));
272    }
273
274    /// AC5: a v2 database migrates up to v3, adding the relation-filter
275    /// composite indexes used by directed edge lookups.
276    #[test]
277    fn migrate_advances_v2_to_v3() {
278        let conn = mem_db();
279        // init_graph_schema already created the v3 indexes; drop them and
280        // rewind the version to simulate a v2 DB.
281        conn.execute_batch(
282            "DROP INDEX IF EXISTS idx_edges_src_rel; DROP INDEX IF EXISTS idx_edges_tgt_rel;",
283        )
284        .unwrap();
285        set_version(&conn, 2);
286        assert!(!has_index(&conn, "idx_edges_src_rel"));
287        assert!(!has_index(&conn, "idx_edges_tgt_rel"));
288
289        let new_version = migrate_graph(&conn, 2).unwrap();
290        assert_eq!(new_version, GRAPH_SCHEMA_VERSION);
291        assert!(has_index(&conn, "idx_edges_src_rel"));
292        assert!(has_index(&conn, "idx_edges_tgt_rel"));
293    }
294
295    /// v3 -> v4: temporal validity columns are added by migration.
296    #[test]
297    fn migrate_advances_v3_to_v4_adds_validity_columns() {
298        let conn = mem_db();
299        // Simulate a v3 DB: drop the v4 columns and rewind the version.
300        conn.execute_batch(
301            "ALTER TABLE nodes DROP COLUMN valid_until;
302             ALTER TABLE nodes DROP COLUMN last_verified;",
303        )
304        .unwrap();
305        set_version(&conn, 3);
306
307        let new_version = migrate_graph(&conn, 3).unwrap();
308        assert_eq!(new_version, GRAPH_SCHEMA_VERSION);
309        let cols: Vec<String> = conn
310            .prepare("PRAGMA table_info(nodes)")
311            .unwrap()
312            .query_map([], |r| r.get::<_, String>(1))
313            .unwrap()
314            .flatten()
315            .collect();
316        assert!(cols.contains(&"valid_until".to_string()));
317        assert!(cols.contains(&"last_verified".to_string()));
318    }
319
320    /// A pre-existing v3 DB gets the v4 columns from `init_graph_schema` alone
321    /// (idempotent ALTER, no explicit `migrate_graph` call required).
322    #[test]
323    fn init_adds_v4_columns_to_existing_v3_table() {
324        let conn = Connection::open_in_memory().unwrap();
325        // Minimal v3-shaped table.
326        conn.execute_batch(
327            "CREATE TABLE nodes (
328                id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL,
329                tags TEXT NOT NULL DEFAULT '', projects TEXT NOT NULL DEFAULT '',
330                agents TEXT NOT NULL DEFAULT '', created TEXT NOT NULL,
331                updated TEXT NOT NULL, body TEXT NOT NULL DEFAULT '',
332                importance REAL NOT NULL DEFAULT 0.5,
333                access_count INTEGER NOT NULL DEFAULT 0,
334                accessed_at TEXT NOT NULL DEFAULT ''
335            );",
336        )
337        .unwrap();
338        init_graph_schema(&conn).unwrap();
339        let cols: Vec<String> = conn
340            .prepare("PRAGMA table_info(nodes)")
341            .unwrap()
342            .query_map([], |r| r.get::<_, String>(1))
343            .unwrap()
344            .flatten()
345            .collect();
346        assert!(cols.contains(&"valid_until".to_string()));
347        assert!(cols.contains(&"last_verified".to_string()));
348    }
349
350    /// AC5: migrating an already-current DB is a no-op.
351    #[test]
352    fn migrate_is_noop_when_current() {
353        let conn = mem_db();
354        let v = migrate_graph(&conn, GRAPH_SCHEMA_VERSION).unwrap();
355        assert_eq!(v, GRAPH_SCHEMA_VERSION);
356    }
357
358    /// AC5: a migration whose step fails rolls back, leaving the version
359    /// unchanged. We force failure by dropping the `nodes` table so the
360    /// `CREATE INDEX … ON nodes` step cannot succeed.
361    #[test]
362    fn migrate_rolls_back_on_failure() {
363        let conn = mem_db();
364        set_version(&conn, 1);
365        // Sabotage the migration target so the v1→v2 CREATE INDEX fails.
366        conn.execute_batch("DROP TABLE nodes;").unwrap();
367
368        let result = migrate_graph(&conn, 1);
369        assert!(result.is_err(), "expected migration to fail");
370        // Version unchanged because the transaction rolled back.
371        assert_eq!(schema_version(&conn).unwrap(), 1);
372    }
373}