From 7856e981ba36a90b4a8b72f3f0a315b0faae3af1 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Tue, 14 Jul 2026 15:18:09 +0700 Subject: [PATCH 1/3] feat: add memory_type to FTS5 index (#662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add memory_type column to FTS5 virtual table definition - Update all 3 sync triggers (INSERT, DELETE, UPDATE) to include memory_type - Add schema migration v13→v14: drop old FTS5 table/triggers, recreate with memory_type, rebuild index - Add tests: memory_type column search and rebuild verification --- crates/uteke-core/src/memory/fts5.rs | 69 +++++++++++++++++++++++--- crates/uteke-core/src/memory/schema.rs | 41 +++++++++++++++ crates/uteke-core/src/memory/store.rs | 2 +- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/crates/uteke-core/src/memory/fts5.rs b/crates/uteke-core/src/memory/fts5.rs index 72c7c6c1..ab483a9f 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 30e6939a..02aacb29 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,43 @@ 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 acf39f22..471d1ade 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 { From 630863bb5931fdf06c3386aa54768f08daf4fe98 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Tue, 14 Jul 2026 16:29:47 +0700 Subject: [PATCH 2/3] fix: cargo fmt --all on schema.rs --- crates/uteke-core/src/memory/schema.rs | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/crates/uteke-core/src/memory/schema.rs b/crates/uteke-core/src/memory/schema.rs index 02aacb29..be14ffec 100644 --- a/crates/uteke-core/src/memory/schema.rs +++ b/crates/uteke-core/src/memory/schema.rs @@ -952,26 +952,17 @@ impl super::Store { /// 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" - ); + 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), - [], - ); + 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", []); + let _ = self.conn.execute("DROP TABLE IF EXISTS memories_fts", []); // Recreate FTS5 with memory_type column. self.init_fts5()?; From 163cea03fb29b3245519852d7e6fa0c8e8ffbf31 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Tue, 14 Jul 2026 16:53:42 +0700 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20update=20schema=20version=20assertio?= =?UTF-8?q?ns=2013=E2=86=9214=20in=20migration=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 tests hardcoded v13 but CURRENT_SCHEMA_VERSION is now 14 (FTS5 memory_type migration). Updates assertion values and comment chain in crud.rs. Fixes #671 --- crates/uteke-core/src/edges.rs | 2 +- crates/uteke-core/src/memory/crud.rs | 2 +- crates/uteke-core/src/memory/store.rs | 2 +- crates/uteke-core/src/timeline.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/uteke-core/src/edges.rs b/crates/uteke-core/src/edges.rs index 557c41ac..15f3d235 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 619d34e7..a28b6b84 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/store.rs b/crates/uteke-core/src/memory/store.rs index 6d281789..9537f46f 100644 --- a/crates/uteke-core/src/memory/store.rs +++ b/crates/uteke-core/src/memory/store.rs @@ -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 5309c99a..e7948f82 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();