1use std::path::Path;
7
8use rusqlite::{Connection, OptionalExtension};
9
10use crate::errors::{InnateError, Result};
11
12const MIGRATIONS: &[(&str, &str, &str)] = &[
14 ("4.0", "4.1", include_str!("migrations/4.0_to_4.1.sql")),
15 ("4.1", "4.2", include_str!("migrations/4.1_to_4.2.sql")),
16 ("4.2", "4.3", include_str!("migrations/4.2_to_4.3.sql")),
17 ("4.3", "4.4", include_str!("migrations/4.3_to_4.4.sql")),
18 ("4.4", "4.5", include_str!("migrations/4.4_to_4.5.sql")),
19 ("4.5", "4.5.1", include_str!("migrations/4.5_to_4.5.1.sql")),
20 (
21 "4.5.1",
22 "4.5.2",
23 include_str!("migrations/4.5.1_to_4.5.2.sql"),
24 ),
25 ("4.5.2", "4.6", include_str!("migrations/4.5.2_to_4.6.sql")),
26 ("4.6", "4.7", include_str!("migrations/4.6_to_4.7.sql")),
27 ("4.7", "4.8", include_str!("migrations/4.7_to_4.8.sql")),
28 ("4.8", "4.9", include_str!("migrations/4.8_to_4.9.sql")),
29 ("4.9", "4.10", include_str!("migrations/4.9_to_4.10.sql")),
30 ("4.10", "4.11", include_str!("migrations/4.10_to_4.11.sql")),
31 ("4.11", "4.12", include_str!("migrations/4.11_to_4.12.sql")),
32 ("4.12", "4.13", include_str!("migrations/4.12_to_4.13.sql")),
33 ("4.13", "4.14", include_str!("migrations/4.13_to_4.14.sql")),
34 ("4.14", "4.15", include_str!("migrations/4.14_to_4.15.sql")),
35 ("4.15", "4.16", include_str!("migrations/4.15_to_4.16.sql")),
36 ("4.16", "4.17", include_str!("migrations/4.16_to_4.17.sql")),
37 ("4.17", "4.18", include_str!("migrations/4.17_to_4.18.sql")),
38 ("4.18", "4.19", include_str!("migrations/4.18_to_4.19.sql")),
39 ("4.19", "4.20", include_str!("migrations/4.19_to_4.20.sql")),
40 ("4.20", "4.21", include_str!("migrations/4.20_to_4.21.sql")),
41];
42
43const TARGET: &str = "4.21";
44
45pub fn target_version() -> &'static str {
48 TARGET
49}
50
51pub fn run_migrations(db_path: impl AsRef<Path>) -> Result<Vec<String>> {
54 let conn = Connection::open(db_path.as_ref())?;
55 conn.execute_batch(
56 "PRAGMA journal_mode=WAL;
57 PRAGMA foreign_keys=ON;
58 PRAGMA synchronous=NORMAL;
59 -- Migration opens its own connection; without a busy_timeout a second
60 -- process starting concurrently (e.g. the MCP server alongside a CLI
61 -- command) would hit SQLITE_BUSY the instant a step's BEGIN IMMEDIATE
62 -- collides. Match Storage's 5s wait so concurrent starts serialise
63 -- instead of failing the migration outright.
64 PRAGMA busy_timeout=5000;",
65 )?;
66
67 let current = schema_version(&conn)?;
68 if current == TARGET {
69 return Ok(vec![]);
70 }
71
72 let mut applied = vec![];
73 let mut ver = current;
74
75 for (from, to, sql) in MIGRATIONS {
76 if ver_tuple(&ver) >= ver_tuple(to) {
77 continue; }
79 if ver_tuple(&ver) < ver_tuple(from) {
80 return Err(InnateError::Other(format!(
81 "Migration gap: database at {ver}, expected {from}→{to}. \
82 Is the database from an unsupported version?"
83 )));
84 }
85 let copy_last_used = *to == "4.12" && column_exists(&conn, "chunks", "last_used_at")?;
86 let add_provenance =
88 *to == "4.15" && !column_exists(&conn, "confidence_evidence", "provenance")?;
89 let add_fts = *to == "4.16"
93 && column_exists(&conn, "chunks", "content")?
94 && column_exists(&conn, "chunks", "trigger_desc")?
95 && column_exists(&conn, "chunks", "skill_name")?;
96 let add_agent_log = *to == "4.17"
99 && column_exists(&conn, "episodic_log", "trace_id")?
100 && !column_exists(&conn, "episodic_log", "agent")?;
101 let add_agent_chunk = *to == "4.17"
102 && column_exists(&conn, "chunks", "content")?
103 && !column_exists(&conn, "chunks", "agent")?;
104 let backfill_entities = *to == "4.18"
108 && column_exists(&conn, "chunks", "content")?
109 && column_exists(&conn, "chunks", "trigger_desc")?;
110 let add_ts_indexes = *to == "4.19";
114 conn.execute_batch("BEGIN IMMEDIATE")?;
116 let r = conn.execute_batch(sql);
117 match r {
118 Ok(()) => {
119 if add_fts {
120 if let Err(error) =
121 conn.execute_batch(include_str!("migrations/4.16_fts.sql"))
122 {
123 let _ = conn.execute_batch("ROLLBACK");
124 return Err(error.into());
125 }
126 }
127 if add_agent_log {
128 if let Err(error) =
129 conn.execute_batch("ALTER TABLE episodic_log ADD COLUMN agent TEXT")
130 {
131 let _ = conn.execute_batch("ROLLBACK");
132 return Err(error.into());
133 }
134 }
135 if add_agent_chunk {
136 if let Err(error) =
137 conn.execute_batch("ALTER TABLE chunks ADD COLUMN agent TEXT")
138 {
139 let _ = conn.execute_batch("ROLLBACK");
140 return Err(error.into());
141 }
142 }
143 if add_provenance {
144 if let Err(error) = conn.execute_batch(
145 "ALTER TABLE confidence_evidence
146 ADD COLUMN provenance TEXT NOT NULL DEFAULT 'observed'",
147 ) {
148 let _ = conn.execute_batch("ROLLBACK");
149 return Err(error.into());
150 }
151 }
152 if backfill_entities {
153 if let Err(error) = backfill_chunk_entities(&conn) {
154 let _ = conn.execute_batch("ROLLBACK");
155 return Err(error);
156 }
157 }
158 if add_ts_indexes {
159 for (table, col, idx) in [
160 ("episodic_log", "ts", "idx_log_ts"),
161 ("usage_trace", "ts", "idx_trace_ts"),
162 ("feedback_events", "ts", "idx_feedback_ts"),
163 ] {
164 if column_exists(&conn, table, col).unwrap_or(false) {
165 if let Err(error) = conn.execute_batch(&format!(
166 "CREATE INDEX IF NOT EXISTS {idx} ON {table}({col})"
167 )) {
168 let _ = conn.execute_batch("ROLLBACK");
169 return Err(error.into());
170 }
171 }
172 }
173 }
174 if copy_last_used {
175 if let Err(error) = conn.execute(
176 "UPDATE chunks
177 SET last_used_base=CASE
178 WHEN EXISTS (
179 SELECT 1 FROM usage_trace u
180 WHERE u.chunk_id=chunks.id AND u.event='used'
181 ) THEN NULL
182 ELSE last_used_at
183 END",
184 [],
185 ) {
186 let _ = conn.execute_batch("ROLLBACK");
187 return Err(error.into());
188 }
189 }
190 conn.execute_batch("COMMIT")?;
191 applied.push(format!("{from}→{to}"));
192 ver = to.to_string();
193 }
194 Err(e) => {
195 let _ = conn.execute_batch("ROLLBACK");
196 return Err(InnateError::Other(format!(
197 "Migration {from}→{to} failed: {e}"
198 )));
199 }
200 }
201 }
202
203 if ver != TARGET {
204 return Err(InnateError::Other(format!(
205 "After all migrations, schema version is {ver}, expected {TARGET}."
206 )));
207 }
208
209 Ok(applied)
210}
211
212fn backfill_chunk_entities(conn: &Connection) -> Result<()> {
216 let rows: Vec<(String, String, Option<String>)> = {
217 let mut stmt = conn.prepare("SELECT id, content, trigger_desc FROM chunks")?;
218 let mapped = stmt.query_map([], |r| {
219 Ok((
220 r.get::<_, String>(0)?,
221 r.get::<_, String>(1)?,
222 r.get::<_, Option<String>>(2)?,
223 ))
224 })?;
225 mapped.collect::<rusqlite::Result<Vec<_>>>()?
226 };
227 let mut ins = conn.prepare(
228 "INSERT OR IGNORE INTO chunk_entities (chunk_id, entity, etype, weight)
229 VALUES (?1, ?2, ?3, 1.0)",
230 )?;
231 for (id, content, trigger) in rows {
232 for e in crate::entities::extract_entities(&content, trigger.as_deref()) {
233 ins.execute(rusqlite::params![id, e.entity, e.etype])?;
234 }
235 }
236 Ok(())
237}
238
239fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
240 let sql = format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name=?");
241 Ok(conn.query_row(&sql, [column], |row| row.get::<_, i64>(0))? > 0)
242}
243
244fn schema_version(conn: &Connection) -> Result<String> {
245 let has_meta: bool = conn.query_row(
246 "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='meta'",
247 [],
248 |r| r.get::<_, i64>(0),
249 )? > 0;
250
251 if !has_meta {
252 return Err(InnateError::Other(
253 "Database has no meta table — cannot migrate. \
254 Use `innate` to create a fresh database."
255 .into(),
256 ));
257 }
258
259 let ver: Option<String> = conn
260 .query_row(
261 "SELECT value FROM meta WHERE key='schema_version'",
262 [],
263 |r| r.get(0),
264 )
265 .optional()?;
266
267 ver.ok_or_else(|| InnateError::Other("meta table missing schema_version".into()))
268}
269
270fn ver_tuple(v: &str) -> (u32, u32, u32) {
271 let parts: Vec<u32> = v.split('.').filter_map(|s| s.parse().ok()).collect();
272 (
273 parts.first().copied().unwrap_or(0),
274 parts.get(1).copied().unwrap_or(0),
275 parts.get(2).copied().unwrap_or(0),
276 )
277}