Skip to main content

rustbrain_core/storage/
migrations.rs

1//! Schema versioning and migrations for `.brain/db.sqlite`.
2//!
3//! On open, [`migrate`](migrate) applies all steps up to [`SCHEMA_VERSION`].
4//! Opening a database with a *newer* version than this build is a hard error
5//! ([`BrainError::SchemaVersion`](crate::BrainError::SchemaVersion)).
6
7use crate::error::{BrainError, Result};
8use rusqlite::Connection;
9
10/// Current on-disk schema version.
11///
12/// Bump when adding migrations, and document the change in `docs/SCHEMA.md`.
13pub const SCHEMA_VERSION: u32 = 2;
14
15/// Apply all migrations required to reach [`SCHEMA_VERSION`].
16pub 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
143/// v2: MainBrain / SubBrain owner scope on every node.
144fn migrate_v2(conn: &Connection) -> Result<()> {
145    // Fresh DBs that already ran a future create with scope: skip add if present.
146    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        // Idempotent
180        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); // column may only appear via ALTER
190        // Insert without explicit scope uses default after migrate_v2 path on v1-only table.
191        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}