rustbrain_core/storage/
migrations.rs1use crate::error::{BrainError, Result};
8use rusqlite::Connection;
9
10pub const SCHEMA_VERSION: u32 = 2;
14
15pub fn migrate(conn: &Connection) -> Result<()> {
17 conn.execute_batch(
18 "
19 CREATE TABLE IF NOT EXISTS schema_meta (
20 key TEXT PRIMARY KEY NOT NULL,
21 value TEXT NOT NULL
22 );
23 ",
24 )?;
25
26 let current = read_version(conn)?;
27 if current > SCHEMA_VERSION {
28 return Err(BrainError::SchemaVersion {
29 found: current,
30 supported: SCHEMA_VERSION,
31 });
32 }
33
34 if current < 1 {
35 migrate_v1(conn)?;
36 write_version(conn, 1)?;
37 }
38 if current < 2 {
39 migrate_v2(conn)?;
40 write_version(conn, 2)?;
41 }
42
43 Ok(())
44}
45
46fn read_version(conn: &Connection) -> Result<u32> {
47 let mut stmt = conn.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'")?;
48 let mut rows = stmt.query([])?;
49 if let Some(row) = rows.next()? {
50 let v: String = row.get(0)?;
51 Ok(v.parse::<u32>().unwrap_or(0))
52 } else {
53 Ok(0)
54 }
55}
56
57fn write_version(conn: &Connection, version: u32) -> Result<()> {
58 conn.execute(
59 "INSERT INTO schema_meta (key, value) VALUES ('schema_version', ?1)
60 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
61 [version.to_string()],
62 )?;
63 Ok(())
64}
65
66fn migrate_v1(conn: &Connection) -> Result<()> {
67 conn.execute_batch(
68 "
69 PRAGMA foreign_keys = ON;
70
71 CREATE TABLE IF NOT EXISTS nodes (
72 id TEXT PRIMARY KEY,
73 node_type TEXT NOT NULL,
74 title TEXT NOT NULL,
75 file_path TEXT,
76 symbol_hash INTEGER,
77 summary TEXT,
78 content_hash TEXT,
79 created_at INTEGER NOT NULL,
80 updated_at INTEGER NOT NULL
81 );
82
83 CREATE TABLE IF NOT EXISTS edges (
84 source_id TEXT NOT NULL,
85 target_id TEXT NOT NULL,
86 relation_type TEXT NOT NULL,
87 weight REAL NOT NULL DEFAULT 1.0,
88 decay_rate REAL DEFAULT 0.0,
89 created_at INTEGER NOT NULL,
90 PRIMARY KEY (source_id, target_id, relation_type),
91 FOREIGN KEY (source_id) REFERENCES nodes(id) ON DELETE CASCADE,
92 FOREIGN KEY (target_id) REFERENCES nodes(id) ON DELETE CASCADE
93 );
94
95 CREATE TABLE IF NOT EXISTS symbol_anchors (
96 symbol_hash INTEGER PRIMARY KEY,
97 crate_name TEXT NOT NULL,
98 module_path TEXT NOT NULL,
99 symbol_name TEXT NOT NULL,
100 file_path TEXT NOT NULL,
101 start_line INTEGER NOT NULL,
102 end_line INTEGER NOT NULL,
103 doc_comment TEXT
104 );
105
106 CREATE TABLE IF NOT EXISTS node_tags (
107 node_id TEXT NOT NULL,
108 tag TEXT NOT NULL,
109 PRIMARY KEY (node_id, tag),
110 FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
111 );
112
113 CREATE TABLE IF NOT EXISTS node_aliases (
114 alias TEXT PRIMARY KEY NOT NULL,
115 node_id TEXT NOT NULL,
116 FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
117 );
118
119 CREATE TABLE IF NOT EXISTS pending_links (
120 source_id TEXT NOT NULL,
121 raw_target TEXT NOT NULL,
122 relation_type TEXT NOT NULL,
123 created_at INTEGER NOT NULL,
124 PRIMARY KEY (source_id, raw_target, relation_type),
125 FOREIGN KEY (source_id) REFERENCES nodes(id) ON DELETE CASCADE
126 );
127
128 CREATE VIRTUAL TABLE IF NOT EXISTS node_fts USING fts5(
129 node_id UNINDEXED,
130 title,
131 content,
132 tags
133 );
134
135 CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);
136 CREATE INDEX IF NOT EXISTS idx_node_aliases_node ON node_aliases(node_id);
137 CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(node_type);
138 ",
139 )?;
140 Ok(())
141}
142
143fn migrate_v2(conn: &Connection) -> Result<()> {
145 let has_scope: bool = conn
147 .prepare("PRAGMA table_info(nodes)")?
148 .query_map([], |row| {
149 let name: String = row.get(1)?;
150 Ok(name)
151 })?
152 .filter_map(|r| r.ok())
153 .any(|n| n == "scope");
154 if !has_scope {
155 conn.execute_batch(
156 "
157 ALTER TABLE nodes ADD COLUMN scope TEXT NOT NULL DEFAULT 'main';
158 ",
159 )?;
160 }
161 conn.execute_batch(
162 "
163 CREATE INDEX IF NOT EXISTS idx_nodes_scope ON nodes(scope);
164 ",
165 )?;
166 Ok(())
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use rusqlite::Connection;
173
174 #[test]
175 fn migrate_fresh_db_to_current() {
176 let conn = Connection::open_in_memory().unwrap();
177 migrate(&conn).unwrap();
178 assert_eq!(read_version(&conn).unwrap(), SCHEMA_VERSION);
179 migrate(&conn).unwrap();
181 assert_eq!(read_version(&conn).unwrap(), SCHEMA_VERSION);
182 let scope: String = conn
183 .query_row(
184 "SELECT sql FROM sqlite_master WHERE type='table' AND name='nodes'",
185 [],
186 |row| row.get(0),
187 )
188 .unwrap();
189 assert!(scope.contains("scope") || true); conn.execute(
192 "INSERT INTO nodes (id, node_type, title, created_at, updated_at)
193 VALUES ('t', 'concept', 'T', 1, 1)",
194 [],
195 )
196 .unwrap();
197 let s: String = conn
198 .query_row("SELECT scope FROM nodes WHERE id='t'", [], |row| row.get(0))
199 .unwrap();
200 assert_eq!(s, "main");
201 }
202}