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 = 1;
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
39    Ok(())
40}
41
42fn read_version(conn: &Connection) -> Result<u32> {
43    let mut stmt = conn.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'")?;
44    let mut rows = stmt.query([])?;
45    if let Some(row) = rows.next()? {
46        let v: String = row.get(0)?;
47        Ok(v.parse::<u32>().unwrap_or(0))
48    } else {
49        Ok(0)
50    }
51}
52
53fn write_version(conn: &Connection, version: u32) -> Result<()> {
54    conn.execute(
55        "INSERT INTO schema_meta (key, value) VALUES ('schema_version', ?1)
56         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
57        [version.to_string()],
58    )?;
59    Ok(())
60}
61
62fn migrate_v1(conn: &Connection) -> Result<()> {
63    conn.execute_batch(
64        "
65        PRAGMA foreign_keys = ON;
66
67        CREATE TABLE IF NOT EXISTS nodes (
68            id           TEXT PRIMARY KEY,
69            node_type    TEXT NOT NULL,
70            title        TEXT NOT NULL,
71            file_path    TEXT,
72            symbol_hash  INTEGER,
73            summary      TEXT,
74            content_hash TEXT,
75            created_at   INTEGER NOT NULL,
76            updated_at   INTEGER NOT NULL
77        );
78
79        CREATE TABLE IF NOT EXISTS edges (
80            source_id     TEXT NOT NULL,
81            target_id     TEXT NOT NULL,
82            relation_type TEXT NOT NULL,
83            weight        REAL NOT NULL DEFAULT 1.0,
84            decay_rate    REAL DEFAULT 0.0,
85            created_at    INTEGER NOT NULL,
86            PRIMARY KEY (source_id, target_id, relation_type),
87            FOREIGN KEY (source_id) REFERENCES nodes(id) ON DELETE CASCADE,
88            FOREIGN KEY (target_id) REFERENCES nodes(id) ON DELETE CASCADE
89        );
90
91        CREATE TABLE IF NOT EXISTS symbol_anchors (
92            symbol_hash  INTEGER PRIMARY KEY,
93            crate_name   TEXT NOT NULL,
94            module_path  TEXT NOT NULL,
95            symbol_name  TEXT NOT NULL,
96            file_path    TEXT NOT NULL,
97            start_line   INTEGER NOT NULL,
98            end_line     INTEGER NOT NULL,
99            doc_comment  TEXT
100        );
101
102        CREATE TABLE IF NOT EXISTS node_tags (
103            node_id TEXT NOT NULL,
104            tag     TEXT NOT NULL,
105            PRIMARY KEY (node_id, tag),
106            FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
107        );
108
109        CREATE TABLE IF NOT EXISTS node_aliases (
110            alias   TEXT PRIMARY KEY NOT NULL,
111            node_id TEXT NOT NULL,
112            FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
113        );
114
115        CREATE TABLE IF NOT EXISTS pending_links (
116            source_id     TEXT NOT NULL,
117            raw_target    TEXT NOT NULL,
118            relation_type TEXT NOT NULL,
119            created_at    INTEGER NOT NULL,
120            PRIMARY KEY (source_id, raw_target, relation_type),
121            FOREIGN KEY (source_id) REFERENCES nodes(id) ON DELETE CASCADE
122        );
123
124        CREATE VIRTUAL TABLE IF NOT EXISTS node_fts USING fts5(
125            node_id UNINDEXED,
126            title,
127            content,
128            tags
129        );
130
131        CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);
132        CREATE INDEX IF NOT EXISTS idx_node_aliases_node ON node_aliases(node_id);
133        CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(node_type);
134        ",
135    )?;
136    Ok(())
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use rusqlite::Connection;
143
144    #[test]
145    fn migrate_fresh_db_to_v1() {
146        let conn = Connection::open_in_memory().unwrap();
147        migrate(&conn).unwrap();
148        assert_eq!(read_version(&conn).unwrap(), SCHEMA_VERSION);
149        // Idempotent
150        migrate(&conn).unwrap();
151        assert_eq!(read_version(&conn).unwrap(), SCHEMA_VERSION);
152    }
153}