diff --git a/crates/uteke-core/src/edges.rs b/crates/uteke-core/src/edges.rs index 557c41a..15f3d23 100644 --- a/crates/uteke-core/src/edges.rs +++ b/crates/uteke-core/src/edges.rs @@ -1276,7 +1276,7 @@ mod tests { fn migration_dispatcher_reaches_v8() { let store = Store::open(":memory:").unwrap(); let v = store.schema_version().unwrap(); - assert_eq!(v, 13, "fresh store must reach CURRENT_SCHEMA_VERSION=13"); + assert_eq!(v, 14, "fresh store must reach CURRENT_SCHEMA_VERSION=14"); // memory_edges table must exist and be queryable after migration. let n = store.count_memory_edges().unwrap(); diff --git a/crates/uteke-core/src/memory/crud.rs b/crates/uteke-core/src/memory/crud.rs index 619d34e..a28b6b8 100644 --- a/crates/uteke-core/src/memory/crud.rs +++ b/crates/uteke-core/src/memory/crud.rs @@ -705,6 +705,6 @@ mod content_type_tests { let store = super::super::store::Store::open(":memory:").unwrap(); assert!(store.column_exists("content_type")); let version = store.schema_version().unwrap(); - assert_eq!(version, 13); // v13 = global docs no namespace (#614); v12 = hierarchical docs (#438); v11 = document engine (#406); v10 = source columns (#348); v9 = timeline (#347); v8 = edges + slug; v7 = graph + assert_eq!(version, 14); // v14 = FTS5 memory_type column (#662); v13 = global docs no namespace (#614); v12 = hierarchical docs (#438); v11 = document engine (#406); v10 = source columns (#348); v9 = timeline (#347); v8 = edges + slug; v7 = graph } } diff --git a/crates/uteke-core/src/memory/fts5.rs b/crates/uteke-core/src/memory/fts5.rs index 72c7c6c..ab483a9 100644 --- a/crates/uteke-core/src/memory/fts5.rs +++ b/crates/uteke-core/src/memory/fts5.rs @@ -31,6 +31,7 @@ impl super::Store { content, tags, namespace, + memory_type, content='memories', content_rowid='rowid' ); @@ -45,20 +46,20 @@ impl super::Store { .execute_batch( r#" CREATE TRIGGER IF NOT EXISTS memories_fts_ai AFTER INSERT ON memories BEGIN - INSERT INTO memories_fts(rowid, content, tags, namespace) - VALUES (new.rowid, new.content, new.tags, new.namespace); + INSERT INTO memories_fts(rowid, content, tags, namespace, memory_type) + VALUES (new.rowid, new.content, new.tags, new.namespace, new.memory_type); END; CREATE TRIGGER IF NOT EXISTS memories_fts_ad AFTER DELETE ON memories BEGIN - INSERT INTO memories_fts(memories_fts, rowid, content, tags, namespace) - VALUES ('delete', old.rowid, old.content, old.tags, old.namespace); + INSERT INTO memories_fts(memories_fts, rowid, content, tags, namespace, memory_type) + VALUES ('delete', old.rowid, old.content, old.tags, old.namespace, old.memory_type); END; CREATE TRIGGER IF NOT EXISTS memories_fts_au AFTER UPDATE ON memories BEGIN - INSERT INTO memories_fts(memories_fts, rowid, content, tags, namespace) - VALUES ('delete', old.rowid, old.content, old.tags, old.namespace); - INSERT INTO memories_fts(rowid, content, tags, namespace) - VALUES (new.rowid, new.content, new.tags, new.namespace); + INSERT INTO memories_fts(memories_fts, rowid, content, tags, namespace, memory_type) + VALUES ('delete', old.rowid, old.content, old.tags, old.namespace, old.memory_type); + INSERT INTO memories_fts(rowid, content, tags, namespace, memory_type) + VALUES (new.rowid, new.content, new.tags, new.namespace, new.memory_type); END; "#, ) @@ -382,4 +383,56 @@ mod tests { // FTS5 is now auto-initialized on open (#544). assert!(store.fts5_exists().unwrap()); } + + #[test] + fn test_fts5_memory_type_column_search() { + let store = Store::open(":memory:").unwrap(); + store.init_fts5().unwrap(); + + let mut decision = make_test_memory("1", "chose Rust for performance", &[]); + decision.memory_type = "decision".to_string(); + store.insert(&decision).unwrap(); + + let mut fact = make_test_memory("2", "Rust has zero-cost abstractions", &[]); + fact.memory_type = "fact".to_string(); + store.insert(&fact).unwrap(); + + let mut procedure = make_test_memory("3", "compile with cargo build", &[]); + procedure.memory_type = "procedure".to_string(); + store.insert(&procedure).unwrap(); + + // Search by memory_type:decision + let results = store.search_fts5("decision", None, 10).unwrap(); + assert!( + !results.is_empty(), + "Should find at least 1 result for 'decision'" + ); + // memory_type "decision" should match + assert!(results.iter().any(|(m, _)| m.memory_type == "decision")); + + // Search by memory_type:fact + let results = store.search_fts5("fact", None, 10).unwrap(); + assert!( + !results.is_empty(), + "Should find at least 1 result for 'fact'" + ); + assert!(results.iter().any(|(m, _)| m.memory_type == "fact")); + } + + #[test] + fn test_fts5_memory_type_rebuild() { + let store = Store::open(":memory:").unwrap(); + store.init_fts5().unwrap(); + + let mut decision = make_test_memory("1", "deploy strategy", &[]); + decision.memory_type = "decision".to_string(); + store.insert(&decision).unwrap(); + + // Rebuild FTS5 index — memory_type should still be searchable + store.rebuild_fts5().unwrap(); + + let results = store.search_fts5("decision", None, 10).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0.memory_type, "decision"); + } } diff --git a/crates/uteke-core/src/memory/schema.rs b/crates/uteke-core/src/memory/schema.rs index 30e6939..be14ffe 100644 --- a/crates/uteke-core/src/memory/schema.rs +++ b/crates/uteke-core/src/memory/schema.rs @@ -370,6 +370,8 @@ impl super::Store { 12 => self.migrate_v11_to_v12()?, // v13: Global documents — remove namespace isolation (#614) 13 => self.migrate_v12_to_v13()?, + // v14: Add memory_type to FTS5 index (#662) + 14 => self.migrate_v13_to_v14()?, _ => { // No-op for future versions. } @@ -941,4 +943,34 @@ impl super::Store { tracing::info!("Migration v12 to v13 complete: documents are now global"); Ok(()) } + + /// v14: Add memory_type column to FTS5 index (#662). + /// + /// FTS5 virtual tables cannot be ALTERed, so we must: + /// 1. Drop existing triggers (they reference old column set) + /// 2. Drop existing FTS5 table + /// 3. Recreate with memory_type column + /// 4. Rebuild index from existing memories + fn migrate_v13_to_v14(&self) -> Result<(), Error> { + tracing::info!("Applying schema migration v13 to v14: add memory_type to FTS5 index"); + + // Drop old triggers first (they reference the old column set). + for trigger_name in &["memories_fts_ai", "memories_fts_ad", "memories_fts_au"] { + let _ = self + .conn + .execute(&format!("DROP TRIGGER IF EXISTS {}", trigger_name), []); + } + + // Drop old FTS5 table. + let _ = self.conn.execute("DROP TABLE IF EXISTS memories_fts", []); + + // Recreate FTS5 with memory_type column. + self.init_fts5()?; + + // Rebuild FTS5 index from existing memories. + self.rebuild_fts5()?; + + tracing::info!("Migration v13 to v14 complete: memory_type now in FTS5 index"); + Ok(()) + } } diff --git a/crates/uteke-core/src/memory/store.rs b/crates/uteke-core/src/memory/store.rs index ce8bc4b..9537f46 100644 --- a/crates/uteke-core/src/memory/store.rs +++ b/crates/uteke-core/src/memory/store.rs @@ -163,7 +163,7 @@ pub(super) const SCHEMA_INDEXES: &[&str] = &[ ]; /// Current schema version. Increment when adding migrations. -pub(super) const CURRENT_SCHEMA_VERSION: i32 = 13; +pub(super) const CURRENT_SCHEMA_VERSION: i32 = 14; /// Persistent SQLite store for memories. pub struct Store { @@ -1778,7 +1778,7 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(version, 13, "schema_version should be 13 after migration"); + assert_eq!(version, 14, "schema_version should be 14 after migration"); // 7. Verify hierarchy columns now exist (in documents table). let cols = ["parent_id", "path", "depth", "sort_order", "has_children"]; diff --git a/crates/uteke-core/src/timeline.rs b/crates/uteke-core/src/timeline.rs index 5309c99..e7948f8 100644 --- a/crates/uteke-core/src/timeline.rs +++ b/crates/uteke-core/src/timeline.rs @@ -296,7 +296,7 @@ mod tests { fn migration_dispatcher_reaches_v9() { let store = Store::open(":memory:").unwrap(); let v = store.schema_version().unwrap(); - assert_eq!(v, 13, "fresh store must reach CURRENT_SCHEMA_VERSION=13"); + assert_eq!(v, 14, "fresh store must reach CURRENT_SCHEMA_VERSION=14"); // timeline_events table must exist. let m = mem(); store.insert(&m).unwrap();