Skip to content

Commit 4d019fb

Browse files
authored
Merge pull request #6 from AdityaVG13/feat/v050-phase-3a-schema
0.5.0 - feat: add schema versioning and cortex doctor command
2 parents 6c7698a + 145766b commit 4d019fb

2 files changed

Lines changed: 354 additions & 4 deletions

File tree

daemon-rs/src/db.rs

Lines changed: 212 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// SPDX-License-Identifier: MIT
2+
use std::collections::HashSet;
23
use std::path::Path;
34

45
use rusqlite::{params, Connection};
@@ -65,6 +66,176 @@ pub fn configure(conn: &Connection) -> rusqlite::Result<()> {
6566
Ok(())
6667
}
6768

69+
type MigrationDef = (&'static str, &'static str);
70+
71+
const SCHEMA_MIGRATIONS: [MigrationDef; 5] = [
72+
("001_initial_schema", "initial_schema"),
73+
("002_aging_columns", "aging_columns"),
74+
("003_focus_table", "focus_table"),
75+
("004_crystal_tables", "crystal_tables"),
76+
("005_quality_dedup_columns", "quality_dedup_columns"),
77+
];
78+
79+
/// Return ordered schema migration definitions.
80+
pub fn migration_definitions() -> &'static [MigrationDef] {
81+
&SCHEMA_MIGRATIONS
82+
}
83+
84+
/// Ensure schema migration tracking table exists.
85+
pub fn ensure_schema_migrations_table(conn: &Connection) -> rusqlite::Result<()> {
86+
conn.execute_batch(
87+
r#"
88+
CREATE TABLE IF NOT EXISTS schema_migrations (
89+
id INTEGER PRIMARY KEY AUTOINCREMENT,
90+
version TEXT NOT NULL UNIQUE,
91+
name TEXT NOT NULL,
92+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
93+
);
94+
"#,
95+
)?;
96+
Ok(())
97+
}
98+
99+
fn migration_error(msg: impl Into<String>) -> rusqlite::Error {
100+
rusqlite::Error::InvalidParameterName(msg.into())
101+
}
102+
103+
fn apply_migration(conn: &Connection, version: &str) -> rusqlite::Result<()> {
104+
match version {
105+
// Baseline marker for pre-versioned schemas.
106+
"001_initial_schema" => Ok(()),
107+
"002_aging_columns" => {
108+
migrate_aging_columns(conn);
109+
if table_has_column(conn, "memories", "compressed_text")
110+
&& table_has_column(conn, "memories", "age_tier")
111+
&& table_has_column(conn, "decisions", "compressed_text")
112+
&& table_has_column(conn, "decisions", "age_tier")
113+
{
114+
Ok(())
115+
} else {
116+
Err(migration_error(
117+
"aging migration did not create expected columns",
118+
))
119+
}
120+
}
121+
"003_focus_table" => {
122+
migrate_focus_table(conn);
123+
if table_exists(conn, "focus_sessions") {
124+
Ok(())
125+
} else {
126+
Err(migration_error("focus table migration did not create focus_sessions"))
127+
}
128+
}
129+
"004_crystal_tables" => {
130+
crate::crystallize::migrate_crystal_tables(conn);
131+
if table_exists(conn, "memory_clusters") && table_exists(conn, "cluster_members") {
132+
Ok(())
133+
} else {
134+
Err(migration_error(
135+
"crystal migration did not create memory_clusters/cluster_members",
136+
))
137+
}
138+
}
139+
"005_quality_dedup_columns" => {
140+
ensure_column(
141+
conn,
142+
"memories",
143+
"ALTER TABLE memories ADD COLUMN merged_count INTEGER DEFAULT 0",
144+
)?;
145+
ensure_column(
146+
conn,
147+
"memories",
148+
"ALTER TABLE memories ADD COLUMN quality INTEGER DEFAULT 50",
149+
)?;
150+
ensure_column(
151+
conn,
152+
"decisions",
153+
"ALTER TABLE decisions ADD COLUMN merged_count INTEGER DEFAULT 0",
154+
)?;
155+
ensure_column(
156+
conn,
157+
"decisions",
158+
"ALTER TABLE decisions ADD COLUMN quality INTEGER DEFAULT 50",
159+
)?;
160+
let _ = conn.execute(
161+
"UPDATE memories SET merged_count = 0 WHERE merged_count IS NULL",
162+
[],
163+
);
164+
let _ = conn.execute("UPDATE memories SET quality = 50 WHERE quality IS NULL", []);
165+
let _ = conn.execute(
166+
"UPDATE decisions SET merged_count = 0 WHERE merged_count IS NULL",
167+
[],
168+
);
169+
let _ = conn.execute("UPDATE decisions SET quality = 50 WHERE quality IS NULL", []);
170+
Ok(())
171+
}
172+
other => Err(migration_error(format!("unknown schema migration: {other}"))),
173+
}
174+
}
175+
176+
/// Return already-applied migration versions.
177+
pub fn applied_migration_versions(conn: &Connection) -> rusqlite::Result<Vec<String>> {
178+
ensure_schema_migrations_table(conn)?;
179+
let mut stmt =
180+
conn.prepare("SELECT version FROM schema_migrations ORDER BY id ASC, version ASC")?;
181+
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
182+
Ok(rows.filter_map(|r| r.ok()).collect())
183+
}
184+
185+
/// Return pending migration versions in execution order.
186+
pub fn pending_migration_versions(conn: &Connection) -> rusqlite::Result<Vec<String>> {
187+
let applied: HashSet<String> = applied_migration_versions(conn)?.into_iter().collect();
188+
let mut pending = Vec::new();
189+
for (version, _) in migration_definitions() {
190+
if !applied.contains(*version) {
191+
pending.push((*version).to_string());
192+
}
193+
}
194+
Ok(pending)
195+
}
196+
197+
/// Execute pending schema migrations in-order and record each in
198+
/// `schema_migrations`. Returns the number of newly-applied migrations.
199+
pub fn run_pending_migrations(conn: &Connection) -> usize {
200+
if let Err(e) = ensure_schema_migrations_table(conn) {
201+
eprintln!("[db] schema migration setup failed: {e}");
202+
return 0;
203+
}
204+
205+
let mut applied_set: HashSet<String> = match applied_migration_versions(conn) {
206+
Ok(v) => v.into_iter().collect(),
207+
Err(e) => {
208+
eprintln!("[db] failed to read applied migrations: {e}");
209+
return 0;
210+
}
211+
};
212+
213+
let mut applied_count = 0usize;
214+
for (version, name) in migration_definitions() {
215+
if applied_set.contains(*version) {
216+
continue;
217+
}
218+
219+
if let Err(e) = apply_migration(conn, version) {
220+
eprintln!("[db] migration {version} ({name}) failed: {e}");
221+
break;
222+
}
223+
224+
if let Err(e) = conn.execute(
225+
"INSERT INTO schema_migrations (version, name) VALUES (?1, ?2)",
226+
params![version, name],
227+
) {
228+
eprintln!("[db] failed to record migration {version} ({name}): {e}");
229+
break;
230+
}
231+
232+
applied_set.insert((*version).to_string());
233+
applied_count += 1;
234+
}
235+
236+
applied_count
237+
}
238+
68239
/// Create all 12 application tables and supporting indexes if they do not
69240
/// already exist.
70241
pub fn initialize_schema(conn: &Connection) -> rusqlite::Result<()> {
@@ -231,6 +402,13 @@ pub fn initialize_schema(conn: &Connection) -> rusqlite::Result<()> {
231402
hits INTEGER DEFAULT 0
232403
);
233404
405+
CREATE TABLE IF NOT EXISTS schema_migrations (
406+
id INTEGER PRIMARY KEY AUTOINCREMENT,
407+
version TEXT NOT NULL UNIQUE,
408+
name TEXT NOT NULL,
409+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
410+
);
411+
234412
-- FTS5 full-text search indexes (trigram tokenizer for code/identifier matching)
235413
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
236414
text, source, tags,
@@ -706,7 +884,7 @@ fn ensure_column(conn: &Connection, table: &str, alter_sql: &str) -> rusqlite::R
706884
}
707885
}
708886

709-
fn table_exists(conn: &Connection, table: &str) -> bool {
887+
pub fn table_exists(conn: &Connection, table: &str) -> bool {
710888
conn.query_row(
711889
"SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?1 LIMIT 1",
712890
params![table],
@@ -1216,6 +1394,7 @@ mod tests {
12161394
"feed",
12171395
"feed_acks",
12181396
"context_cache",
1397+
"schema_migrations",
12191398
"memories_fts",
12201399
"decisions_fts",
12211400
"recall_feedback",
@@ -1364,6 +1543,38 @@ mod tests {
13641543
}
13651544
}
13661545

1546+
#[test]
1547+
fn test_run_pending_migrations_applies_all_once() {
1548+
let conn = Connection::open_in_memory().unwrap();
1549+
configure(&conn).unwrap();
1550+
initialize_schema(&conn).unwrap();
1551+
1552+
let first_applied = run_pending_migrations(&conn);
1553+
assert_eq!(first_applied, migration_definitions().len());
1554+
1555+
let second_applied = run_pending_migrations(&conn);
1556+
assert_eq!(second_applied, 0);
1557+
1558+
let pending = pending_migration_versions(&conn).unwrap();
1559+
assert!(
1560+
pending.is_empty(),
1561+
"no pending migrations expected after first run"
1562+
);
1563+
1564+
let recorded: i64 = conn
1565+
.query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| row.get(0))
1566+
.unwrap();
1567+
assert_eq!(recorded as usize, migration_definitions().len());
1568+
1569+
assert!(table_has_column(&conn, "memories", "merged_count"));
1570+
assert!(table_has_column(&conn, "memories", "quality"));
1571+
assert!(table_has_column(&conn, "decisions", "merged_count"));
1572+
assert!(table_has_column(&conn, "decisions", "quality"));
1573+
assert!(table_exists(&conn, "focus_sessions"));
1574+
assert!(table_exists(&conn, "memory_clusters"));
1575+
assert!(table_exists(&conn, "cluster_members"));
1576+
}
1577+
13671578
#[test]
13681579
fn test_team_migration_creates_owner_scoped_schema() {
13691580
let conn = Connection::open_in_memory().unwrap();

0 commit comments

Comments
 (0)