Skip to main content

innate_core/
migrate.rs

1//! Schema migration runner — 4.0 → current chain.
2//!
3//! Each step is atomic: BEGIN IMMEDIATE … COMMIT. Any failure rolls back the
4//! entire step and returns an error — no half-migrated state.
5
6use std::path::Path;
7
8use rusqlite::{Connection, OptionalExtension};
9
10use crate::errors::{InnateError, Result};
11
12// Embedded migration SQL ordered from lowest to highest target version.
13const 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
45/// The schema version this binary migrates to (single source of truth for callers
46/// that want to report it, e.g. the CLI `migrate` "nothing to do" message).
47pub fn target_version() -> &'static str {
48    TARGET
49}
50
51/// Run all pending migrations on `db_path`. Idempotent if already at target.
52/// Returns the list of migration steps executed.
53pub 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; // already at or beyond this step
78        }
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        // 方案 C:provenance 列在 4.14→4.15 条件添加(ALTER ADD COLUMN 无 IF NOT EXISTS)。
87        let add_provenance =
88            *to == "4.15" && !column_exists(&conn, "confidence_evidence", "provenance")?;
89        // 混合检索:FTS5 词法通道。仅当 chunks 拥有索引列时建/灌(部分 schema
90        // 测试夹具缺这些列 → 跳过,不致命;真实库恒满足)。DDL 自带 IF NOT EXISTS,
91        // 回填 DELETE+INSERT,可重复执行。
92        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        // agent 来源列(4.16→4.17)。ALTER ADD COLUMN 无 IF NOT EXISTS,故按表是否
97        // 存在(借已知列探测)且 agent 列尚缺时条件添加,对部分 schema 夹具不致命。
98        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        // 关联实体索引回填(4.17→4.18):DDL 由本步 SQL 建表,存量 chunk 的实体由
105        // Rust(extract_entities)回填。仅当 chunks 拥有内容列时执行(部分 schema
106        // 测试夹具缺列 → 跳过,不致命;真实库恒满足)。可重复执行(INSERT OR IGNORE)。
107        let backfill_entities = *to == "4.18"
108            && column_exists(&conn, "chunks", "content")?
109            && column_exists(&conn, "chunks", "trigger_desc")?;
110        // 可观测性 ts 索引(4.18→4.19):CREATE INDEX 依赖既有表的 ts 列。部分迁移
111        // 测试夹具只建简化 schema(缺这些表/列),故每个索引各自用 column_exists 守卫;
112        // 真实库三表恒有 ts,索引照常建立。各索引独立判断(夹具可能只缺其中之一)。
113        let add_ts_indexes = *to == "4.19";
114        // Run the step atomically.
115        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
212/// Backfill `chunk_entities` from existing chunks using the deterministic
213/// extractor. Runs inside the migration's open transaction. Idempotent
214/// (`INSERT OR IGNORE` on the composite PK), so re-running is safe.
215fn 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}