1use rusqlite::{Connection, params};
4
5use crate::error::{KernelError, Result};
6
7pub const GRAPH_SCHEMA_VERSION: u32 = 4;
9
10pub 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
23pub 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 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 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 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
80fn 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
91pub fn init_graph_schema(conn: &Connection) -> Result<()> {
95 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 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 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 #[test]
260 fn migrate_advances_v1_to_current() {
261 let conn = mem_db();
262 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 #[test]
277 fn migrate_advances_v2_to_v3() {
278 let conn = mem_db();
279 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 #[test]
297 fn migrate_advances_v3_to_v4_adds_validity_columns() {
298 let conn = mem_db();
299 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 #[test]
323 fn init_adds_v4_columns_to_existing_v3_table() {
324 let conn = Connection::open_in_memory().unwrap();
325 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 #[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 #[test]
362 fn migrate_rolls_back_on_failure() {
363 let conn = mem_db();
364 set_version(&conn, 1);
365 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 assert_eq!(schema_version(&conn).unwrap(), 1);
372 }
373}