1use rusqlite::Connection;
2
3pub 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 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 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 conn.execute_batch(BRANCH_TABLES)?;
96
97 for table in FTS_TABLES {
98 create_fts(conn, table, "fts5")?;
99 }
100
101 Ok(())
102}
103
104const 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
121pub(crate) const FTS_TABLES: [&str; 3] = ["memories", "code_chunks", "session_chunks"];
123
124pub(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 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
172fn 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 match conn.execute(
184 &format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"),
185 [],
186 ) {
187 Ok(_) => Ok(()),
188 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 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 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 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 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 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 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 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 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}