Skip to main content

whycodes_storage/
migrations.rs

1use rusqlite::Connection;
2
3/// Run all migrations on the given connection, creating tables
4/// if they do not already exist and applying additive column upgrades.
5pub fn run_migrations(conn: &Connection) -> Result<(), rusqlite::Error> {
6    conn.execute_batch(
7        "
8        CREATE TABLE IF NOT EXISTS sessions (
9            id          TEXT PRIMARY KEY,
10            title       TEXT,
11            created_at  TEXT,
12            updated_at  TEXT,
13            project_path TEXT
14        );
15
16        CREATE TABLE IF NOT EXISTS messages (
17            id           TEXT PRIMARY KEY,
18            session_id   TEXT,
19            role         TEXT,
20            content      TEXT,
21            tool_call_id TEXT,
22            name         TEXT,
23            created_at   TEXT,
24            FOREIGN KEY (session_id) REFERENCES sessions(id)
25        );
26        -- Speeds delete/list/count by session (full replace on every persist).
27        CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
28
29        CREATE TABLE IF NOT EXISTS state (
30            key   TEXT PRIMARY KEY,
31            value TEXT
32        );
33        ",
34    )?;
35
36    // Additive: token usage on sessions (existing DBs pre-date these columns).
37    ensure_column(
38        conn,
39        "sessions",
40        "input_tokens",
41        "INTEGER NOT NULL DEFAULT 0",
42    )?;
43    ensure_column(
44        conn,
45        "sessions",
46        "output_tokens",
47        "INTEGER NOT NULL DEFAULT 0",
48    )?;
49    ensure_column(conn, "sessions", "cache_creation_input_tokens", "INTEGER")?;
50    ensure_column(conn, "sessions", "cache_read_input_tokens", "INTEGER")?;
51
52    // Cross-session semantic / auto memory (plan-memory).
53    conn.execute_batch(
54        "
55        CREATE TABLE IF NOT EXISTS memories (
56            id               TEXT PRIMARY KEY,
57            project_key      TEXT NOT NULL,
58            text             TEXT NOT NULL,
59            embedding        BLOB NOT NULL,
60            source_session   TEXT,
61            created_at       TEXT NOT NULL,
62            last_recalled_at TEXT,
63            recall_count     INTEGER NOT NULL DEFAULT 0
64        );
65        CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_key);
66
67        CREATE TABLE IF NOT EXISTS code_chunks (
68            id          TEXT PRIMARY KEY,
69            project_key TEXT NOT NULL,
70            path        TEXT NOT NULL,
71            start_line  INTEGER NOT NULL,
72            end_line    INTEGER NOT NULL,
73            text        TEXT NOT NULL,
74            embedding   BLOB NOT NULL,
75            updated_at  TEXT NOT NULL
76        );
77        CREATE INDEX IF NOT EXISTS idx_code_chunks_project ON code_chunks(project_key);
78
79        CREATE TABLE IF NOT EXISTS session_chunks (
80            id           TEXT PRIMARY KEY,
81            project_key  TEXT NOT NULL,
82            session_id   TEXT NOT NULL,
83            turn_index   INTEGER NOT NULL,
84            text         TEXT NOT NULL,
85            embedding    BLOB NOT NULL,
86            created_at   TEXT NOT NULL
87        );
88        CREATE INDEX IF NOT EXISTS idx_session_chunks_project ON session_chunks(project_key);
89        ",
90    )?;
91
92    // Conversation branches (`/branch`, `/tree`). Each stored branch is a
93    // full root-to-leaf copy kept apart from `messages`, so `messages` stays
94    // the active path that every reader (an older binary included) loads.
95    conn.execute_batch(BRANCH_TABLES)?;
96
97    for table in FTS_TABLES {
98        create_fts(conn, table, "fts5")?;
99    }
100
101    Ok(())
102}
103
104/// Branch storage, see the call site in [`run_migrations`].
105const BRANCH_TABLES: &str = "
106        CREATE TABLE IF NOT EXISTS session_branches (
107            session_id TEXT NOT NULL,
108            ord        INTEGER NOT NULL,
109            name       TEXT,
110            PRIMARY KEY (session_id, ord)
111        );
112        CREATE TABLE IF NOT EXISTS branch_messages (
113            session_id TEXT NOT NULL,
114            ord        INTEGER NOT NULL,
115            seq        INTEGER NOT NULL,
116            content    TEXT NOT NULL,
117            PRIMARY KEY (session_id, ord, seq)
118        );
119        ";
120
121/// Record tables with an FTS5 candidate index on their `text` column.
122pub(crate) const FTS_TABLES: [&str; 3] = ["memories", "code_chunks", "session_chunks"];
123
124/// External-content FTS5 index over `{table}.text`, kept in sync by triggers
125/// so a row and its index entry land in the same transaction. Keyed by the
126/// implicit rowid, which is stable because nothing here runs `VACUUM`.
127///
128/// Returns `false` when this SQLite build has no `module` (FTS5 compiled
129/// out); search then falls back to a scan. A new index is backfilled once.
130pub(crate) fn create_fts(
131    conn: &Connection,
132    table: &str,
133    module: &str,
134) -> Result<bool, rusqlite::Error> {
135    let fts = format!("{table}_fts");
136    let existed = table_exists(conn, &fts)?;
137    // table/module names are internal constants only.
138    let created = conn.execute_batch(&format!(
139        "
140        CREATE VIRTUAL TABLE IF NOT EXISTS {fts}
141            USING {module}(text, content='{table}', content_rowid='rowid');
142        CREATE TRIGGER IF NOT EXISTS {fts}_ai AFTER INSERT ON {table} BEGIN
143            INSERT INTO {fts}(rowid, text) VALUES (new.rowid, new.text);
144        END;
145        CREATE TRIGGER IF NOT EXISTS {fts}_ad AFTER DELETE ON {table} BEGIN
146            INSERT INTO {fts}({fts}, rowid, text) VALUES ('delete', old.rowid, old.text);
147        END;
148        CREATE TRIGGER IF NOT EXISTS {fts}_au AFTER UPDATE OF text ON {table} BEGIN
149            INSERT INTO {fts}({fts}, rowid, text) VALUES ('delete', old.rowid, old.text);
150            INSERT INTO {fts}(rowid, text) VALUES (new.rowid, new.text);
151        END;
152        "
153    ));
154    match created {
155        Ok(()) => {}
156        Err(e) if e.to_string().contains("no such module") => return Ok(false),
157        Err(e) => return Err(e),
158    }
159    if !existed {
160        conn.execute_batch(&format!("INSERT INTO {fts}({fts}) VALUES ('rebuild');"))?;
161    }
162    Ok(true)
163}
164
165fn table_exists(conn: &Connection, table: &str) -> Result<bool, rusqlite::Error> {
166    let mut stmt =
167        conn.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1 LIMIT 1")?;
168    let mut rows = stmt.query(rusqlite::params![table])?;
169    Ok(rows.next()?.is_some())
170}
171
172/// Add a column when missing. SQLite has no `ADD COLUMN IF NOT EXISTS`.
173fn ensure_column(
174    conn: &Connection,
175    table: &str,
176    column: &str,
177    decl: &str,
178) -> Result<(), rusqlite::Error> {
179    if column_exists(conn, table, column)? {
180        return Ok(());
181    }
182    // table/column names are internal constants only.
183    match conn.execute(
184        &format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"),
185        [],
186    ) {
187        Ok(_) => Ok(()),
188        // Race or pragma false-negative: treat as already migrated.
189        Err(e) if e.to_string().contains("duplicate column") => Ok(()),
190        Err(e) => Err(e),
191    }
192}
193
194fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool, rusqlite::Error> {
195    // Table-valued pragma is more reliable than iterating `PRAGMA table_info`
196    // via a prepared statement on some rusqlite/SQLite combinations.
197    let mut stmt = conn.prepare("SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2 LIMIT 1")?;
198    let mut rows = stmt.query(rusqlite::params![table, column])?;
199    Ok(rows.next()?.is_some())
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn migrations_add_usage_columns_on_legacy_schema() {
208        let conn = Connection::open_in_memory().unwrap();
209        // Pre-usage schema (as first shipped).
210        conn.execute_batch(
211            "
212            CREATE TABLE sessions (
213                id TEXT PRIMARY KEY,
214                title TEXT,
215                created_at TEXT,
216                updated_at TEXT,
217                project_path TEXT
218            );
219            CREATE TABLE messages (
220                id TEXT PRIMARY KEY,
221                session_id TEXT,
222                role TEXT,
223                content TEXT,
224                tool_call_id TEXT,
225                name TEXT,
226                created_at TEXT
227            );
228            CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT);
229            ",
230        )
231        .unwrap();
232
233        run_migrations(&conn).unwrap();
234        assert!(column_exists(&conn, "sessions", "input_tokens").unwrap());
235        assert!(column_exists(&conn, "sessions", "output_tokens").unwrap());
236        assert!(column_exists(&conn, "sessions", "cache_creation_input_tokens").unwrap());
237        assert!(column_exists(&conn, "sessions", "cache_read_input_tokens").unwrap());
238
239        // Idempotent.
240        run_migrations(&conn).unwrap();
241        assert!(column_exists(&conn, "memories", "embedding").unwrap());
242        assert!(table_exists(&conn, "memories").unwrap());
243        assert!(table_exists(&conn, "session_branches").unwrap());
244        assert!(table_exists(&conn, "branch_messages").unwrap());
245    }
246
247    fn fts_hits(conn: &Connection, table: &str, word: &str) -> i64 {
248        conn.query_row(
249            &format!("SELECT COUNT(*) FROM {table}_fts WHERE {table}_fts MATCH ?1"),
250            [word],
251            |r| r.get(0),
252        )
253        .unwrap()
254    }
255
256    #[test]
257    fn fts_backfills_existing_rows_once_and_tracks_writes() {
258        let conn = Connection::open_in_memory().unwrap();
259        // A pre-FTS database that already holds a fact.
260        conn.execute_batch(
261            "
262            CREATE TABLE memories (
263                id TEXT PRIMARY KEY, project_key TEXT NOT NULL, text TEXT NOT NULL,
264                embedding BLOB NOT NULL, source_session TEXT, created_at TEXT NOT NULL,
265                last_recalled_at TEXT, recall_count INTEGER NOT NULL DEFAULT 0
266            );
267            INSERT INTO memories (id, project_key, text, embedding, created_at)
268                VALUES ('m1', 'p', 'bundled sqlite on windows', x'', 'now');
269            ",
270        )
271        .unwrap();
272        run_migrations(&conn).unwrap();
273        assert_eq!(fts_hits(&conn, "memories", "bundled"), 1);
274        // Idempotent: a second run does not duplicate index entries.
275        run_migrations(&conn).unwrap();
276        assert_eq!(fts_hits(&conn, "memories", "bundled"), 1);
277
278        conn.execute(
279            "UPDATE memories SET text = 'msvc linker' WHERE id = 'm1'",
280            [],
281        )
282        .unwrap();
283        assert_eq!(fts_hits(&conn, "memories", "bundled"), 0);
284        assert_eq!(fts_hits(&conn, "memories", "linker"), 1);
285        conn.execute("DELETE FROM memories WHERE id = 'm1'", [])
286            .unwrap();
287        assert_eq!(fts_hits(&conn, "memories", "linker"), 0);
288    }
289
290    #[test]
291    fn create_fts_reports_a_missing_module_without_failing() {
292        let conn = Connection::open_in_memory().unwrap();
293        conn.execute_batch("CREATE TABLE notes (text TEXT NOT NULL);")
294            .unwrap();
295        assert!(!create_fts(&conn, "notes", "no_such_fts").unwrap());
296        assert!(!table_exists(&conn, "notes_fts").unwrap());
297        assert!(create_fts(&conn, "notes", "fts5").unwrap());
298    }
299
300    #[test]
301    fn create_fts_propagates_other_errors() {
302        let conn = Connection::open_in_memory().unwrap();
303        // A trigger on a table that does not exist is not a missing module.
304        assert!(create_fts(&conn, "missing", "fts5").is_err());
305    }
306
307    #[test]
308    fn memories_table_created() {
309        let conn = Connection::open_in_memory().unwrap();
310        run_migrations(&conn).unwrap();
311        assert!(table_exists(&conn, "memories").unwrap());
312        run_migrations(&conn).unwrap();
313    }
314
315    #[test]
316    fn messages_session_index_created() {
317        let conn = Connection::open_in_memory().unwrap();
318        run_migrations(&conn).unwrap();
319        let mut stmt = conn
320            .prepare(
321                "SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_messages_session'",
322            )
323            .unwrap();
324        let mut rows = stmt.query([]).unwrap();
325        assert!(rows.next().unwrap().is_some());
326        // Idempotent on legacy DBs that already had messages without the index.
327        run_migrations(&conn).unwrap();
328    }
329
330    #[test]
331    fn run_migrations_fails_when_first_batch_cannot_write() {
332        let conn = Connection::open_in_memory().unwrap();
333        conn.execute_batch("PRAGMA query_only=ON;").unwrap();
334        assert!(run_migrations(&conn).is_err());
335    }
336
337    #[test]
338    fn run_migrations_fails_adding_input_tokens_on_a_view() {
339        let conn = Connection::open_in_memory().unwrap();
340        conn.execute_batch(
341            "
342            CREATE VIEW sessions AS
343                SELECT 'x' AS id, 't' AS title, 'c' AS created_at,
344                       'u' AS updated_at, 'p' AS project_path;
345            CREATE TABLE messages (
346                id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT,
347                tool_call_id TEXT, name TEXT, created_at TEXT
348            );
349            CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT);
350            ",
351        )
352        .unwrap();
353        assert!(run_migrations(&conn).is_err());
354    }
355
356    #[test]
357    fn run_migrations_fails_adding_output_tokens_at_column_limit() {
358        let conn = Connection::open_in_memory().unwrap();
359        let mut cols = vec![
360            "id TEXT PRIMARY KEY".to_string(),
361            "title TEXT".to_string(),
362            "created_at TEXT".to_string(),
363            "updated_at TEXT".to_string(),
364            "project_path TEXT".to_string(),
365            "input_tokens INTEGER NOT NULL DEFAULT 0".to_string(),
366        ];
367        for i in cols.len()..2000 {
368            cols.push(format!("pad_{i} INTEGER"));
369        }
370        conn.execute(&format!("CREATE TABLE sessions ({})", cols.join(",")), [])
371            .unwrap();
372        conn.execute_batch(
373            "
374            CREATE TABLE messages (
375                id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT,
376                tool_call_id TEXT, name TEXT, created_at TEXT
377            );
378            CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT);
379            ",
380        )
381        .unwrap();
382        assert!(run_migrations(&conn).is_err());
383    }
384
385    #[test]
386    fn run_migrations_fails_when_memory_index_cannot_be_created() {
387        let conn = Connection::open_in_memory().unwrap();
388        conn.execute_batch(
389            "
390            CREATE TABLE sessions (
391                id TEXT PRIMARY KEY, title TEXT, created_at TEXT,
392                updated_at TEXT, project_path TEXT
393            );
394            CREATE TABLE messages (
395                id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT,
396                tool_call_id TEXT, name TEXT, created_at TEXT
397            );
398            CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT);
399            CREATE TABLE memories (id TEXT PRIMARY KEY);
400            ",
401        )
402        .unwrap();
403        assert!(run_migrations(&conn).is_err());
404    }
405
406    #[test]
407    fn ensure_column_treats_duplicate_name_as_already_migrated() {
408        let conn = Connection::open_in_memory().unwrap();
409        conn.execute_batch("CREATE TABLE t (col INTEGER)").unwrap();
410        // pragma_table_info is case-sensitive; SQLite column names are not.
411        ensure_column(&conn, "t", "COL", "INTEGER").unwrap();
412    }
413
414    #[test]
415    fn ensure_column_propagates_alter_errors() {
416        let conn = Connection::open_in_memory().unwrap();
417        assert!(ensure_column(&conn, "missing", "col", "INTEGER").is_err());
418    }
419}