Skip to main content

context_forge/storage/
mod.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use r2d2::{CustomizeConnection, Pool};
5use r2d2_sqlite::SqliteConnectionManager;
6use rusqlite::TransactionBehavior;
7
8use crate::entry::ContextEntry;
9use crate::storage::schema::{migrate, row_to_entry};
10use crate::traits::ContextStorage;
11
12/// Forward-only schema migrations and row-to-entry conversion.
13pub mod schema;
14/// FTS5-backed `Searcher` implementation.
15pub mod searcher;
16
17pub use searcher::SqliteSearcher;
18
19/// Create a paired storage + searcher sharing the same connection pool.
20///
21/// # Errors
22///
23/// Returns an error if the database cannot be opened, the connection pool
24/// cannot be built, or migrations fail.
25pub fn open_storage(
26    db_path: &Path,
27    max_entries: usize,
28) -> crate::Result<(SqliteStorage, SqliteSearcher)> {
29    let storage = SqliteStorage::open(db_path, max_entries)?;
30    let searcher = SqliteSearcher::new(storage.pool());
31    Ok((storage, searcher))
32}
33
34#[derive(Debug)]
35struct PragmaCustomizer;
36
37impl CustomizeConnection<rusqlite::Connection, rusqlite::Error> for PragmaCustomizer {
38    fn on_acquire(
39        &self,
40        conn: &mut rusqlite::Connection,
41    ) -> std::result::Result<(), rusqlite::Error> {
42        // busy_timeout must be set before journal_mode=WAL: switching to WAL
43        // briefly takes an exclusive lock, and on a fresh database with
44        // multiple connections racing the switch, an un-armed busy_timeout
45        // means that lock contention fails immediately with SQLITE_BUSY
46        // instead of waiting.
47        conn.execute_batch(
48            "PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;",
49        )?;
50        Ok(())
51    }
52}
53
54/// SQLite-backed implementation of [`ContextStorage`].
55pub struct SqliteStorage {
56    pool: Arc<Pool<SqliteConnectionManager>>,
57    max_entries: usize,
58}
59
60impl SqliteStorage {
61    /// Open (or create) a `SQLite` database at `db_path` and run migrations.
62    ///
63    /// For `":memory:"`, a single-connection pool is used so that all operations
64    /// share the same in-memory database instance.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if the database cannot be opened, the connection
69    /// pool cannot be built, or migrations fail.
70    pub fn open(db_path: &Path, max_entries: usize) -> crate::Result<Self> {
71        let manager = SqliteConnectionManager::file(db_path);
72        let mut builder = Pool::builder().connection_customizer(Box::new(PragmaCustomizer));
73
74        // Each `:memory:` connection is a distinct in-memory database.
75        // Restrict to a single connection so all callers see the same DB.
76        if db_path == Path::new(":memory:") {
77            builder = builder.max_size(1);
78        } else {
79            builder = builder.max_size(4);
80        }
81
82        let pool = builder.build(manager)?;
83
84        let conn = pool.get()?;
85        migrate(&conn)?;
86
87        Ok(Self {
88            pool: Arc::new(pool),
89            max_entries,
90        })
91    }
92
93    /// Return a reference-counted handle to the connection pool so that
94    /// [`SqliteSearcher`] can share it.
95    #[must_use]
96    pub fn pool(&self) -> Arc<Pool<SqliteConnectionManager>> {
97        Arc::clone(&self.pool)
98    }
99
100    /// Return the current schema version from the database.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if the connection pool or query fails.
105    pub fn schema_version(&self) -> crate::Result<i64> {
106        let conn = self.pool.get()?;
107        let version = conn.query_row(
108            "SELECT COALESCE(MAX(version), 0) FROM schema_version",
109            [],
110            |row| row.get(0),
111        )?;
112        Ok(version)
113    }
114
115    /// Run a WAL checkpoint (TRUNCATE mode) to flush the WAL file.
116    ///
117    /// Safe to call at any time; no-op if no WAL pages are pending.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if the connection pool or checkpoint pragma fails.
122    pub fn checkpoint(&self) -> crate::Result<()> {
123        let conn = self.pool.get()?;
124        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
125        Ok(())
126    }
127}
128
129impl ContextStorage for SqliteStorage {
130    fn save(&self, entry: &ContextEntry) -> crate::Result<()> {
131        let mut conn = self.pool.get()?;
132
133        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
134
135        // LRU eviction: only evict when inserting a new entry (not replacing
136        // an existing ID) and currently at capacity.
137        let exists: bool = tx.query_row(
138            "SELECT EXISTS(SELECT 1 FROM entries WHERE id = ?1)",
139            [&entry.id],
140            |r| r.get(0),
141        )?;
142
143        if !exists {
144            let current_count: i64 =
145                tx.query_row("SELECT COUNT(*) FROM entries", [], |r| r.get(0))?;
146
147            let current_count = usize::try_from(current_count).unwrap_or(usize::MAX);
148            if current_count >= self.max_entries {
149                tx.execute(
150                    "DELETE FROM entries WHERE id = (\
151                     SELECT id FROM entries ORDER BY timestamp ASC LIMIT 1)",
152                    [],
153                )?;
154            }
155        }
156
157        let metadata_json = entry
158            .metadata
159            .as_ref()
160            .map(serde_json::to_string)
161            .transpose()
162            .map_err(|e| crate::Error::InvalidEntry(format!("metadata is not valid JSON: {e}")))?;
163
164        tx.execute(
165            "INSERT OR REPLACE INTO entries (
166                id,
167                content,
168                timestamp,
169                kind,
170                scope,
171                session_id,
172                token_count,
173                metadata
174            ) VALUES (
175                ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8
176            )",
177            rusqlite::params![
178                entry.id,
179                entry.content,
180                entry.timestamp,
181                entry.kind,
182                entry.scope,
183                entry.session_id,
184                entry
185                    .token_count
186                    .map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
187                metadata_json,
188            ],
189        )?;
190
191        tx.commit()?;
192
193        Ok(())
194    }
195
196    fn get_top_k(&self, k: usize) -> crate::Result<Vec<ContextEntry>> {
197        let conn = self.pool.get()?;
198        let mut stmt = conn.prepare(
199            "SELECT
200                id,
201                content,
202                timestamp,
203                kind,
204                scope,
205                session_id,
206                token_count,
207                metadata
208             FROM entries
209             ORDER BY timestamp DESC
210             LIMIT ?1",
211        )?;
212
213        let entries = stmt
214            .query_map([i64::try_from(k).unwrap_or(i64::MAX)], row_to_entry)?
215            .collect::<std::result::Result<Vec<_>, _>>()?;
216
217        Ok(entries)
218    }
219
220    fn get_all(&self) -> crate::Result<Vec<ContextEntry>> {
221        let conn = self.pool.get()?;
222        let mut stmt = conn.prepare(
223            "SELECT
224                id,
225                content,
226                timestamp,
227                kind,
228                scope,
229                session_id,
230                token_count,
231                metadata
232             FROM entries
233             ORDER BY timestamp DESC",
234        )?;
235
236        let entries = stmt
237            .query_map([], row_to_entry)?
238            .collect::<std::result::Result<Vec<_>, _>>()?;
239
240        Ok(entries)
241    }
242
243    fn delete(&self, id: &str) -> crate::Result<bool> {
244        let conn = self.pool.get()?;
245        let changes = conn.execute("DELETE FROM entries WHERE id = ?1", [id])?;
246        Ok(changes > 0)
247    }
248
249    fn clear(&self) -> crate::Result<usize> {
250        let conn = self.pool.get()?;
251        let changes = conn.execute("DELETE FROM entries", [])?;
252        Ok(changes)
253    }
254
255    fn clear_scope(&self, scope: &str) -> crate::Result<usize> {
256        let conn = self.pool.get()?;
257        let changes = conn.execute("DELETE FROM entries WHERE scope = ?1", [scope])?;
258        Ok(changes)
259    }
260
261    fn count(&self) -> crate::Result<usize> {
262        let conn = self.pool.get()?;
263        let count: i64 = conn.query_row("SELECT COUNT(*) FROM entries", [], |r| r.get(0))?;
264        Ok(usize::try_from(count).unwrap_or(usize::MAX))
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use std::fs;
271    use std::path::Path;
272    use std::time::{SystemTime, UNIX_EPOCH};
273
274    use rusqlite::Connection;
275
276    use crate::engine::MATCH_ALL_QUERY;
277    use crate::entry::{kind, ContextEntry};
278    use crate::storage::{open_storage, SqliteStorage};
279    use crate::traits::{ContextStorage, Searcher};
280
281    fn temp_db_path(name: &str) -> std::path::PathBuf {
282        let nanos = SystemTime::now()
283            .duration_since(UNIX_EPOCH)
284            .expect("clock drift before unix epoch")
285            .as_nanos();
286        std::env::temp_dir().join(format!("cf-storage-{name}-{nanos}.db"))
287    }
288
289    fn make_entry(id: &str, content: &str, timestamp: i64, kind: &str) -> ContextEntry {
290        ContextEntry {
291            id: id.into(),
292            content: content.into(),
293            timestamp,
294            kind: kind.to_owned(),
295            scope: None,
296            session_id: None,
297            token_count: None,
298            metadata: None,
299        }
300    }
301
302    fn make_scoped_entry(id: &str, content: &str, timestamp: i64, scope: &str) -> ContextEntry {
303        ContextEntry {
304            id: id.into(),
305            content: content.into(),
306            timestamp,
307            kind: kind::MANUAL.to_owned(),
308            scope: Some(scope.to_owned()),
309            session_id: None,
310            token_count: None,
311            metadata: None,
312        }
313    }
314
315    #[test]
316    fn checkpoint_runs_without_error() {
317        let dir = tempfile::tempdir().unwrap();
318        let storage = SqliteStorage::open(dir.path().join("test.db").as_path(), 100).unwrap();
319        assert!(storage.checkpoint().is_ok());
320    }
321
322    #[test]
323    fn test_save_and_count() {
324        let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
325        let entry = make_entry("e1", "hello world", 1000, kind::MANUAL);
326        storage.save(&entry).unwrap();
327        assert_eq!(storage.count().unwrap(), 1);
328    }
329
330    #[test]
331    fn test_save_and_get_top_k() {
332        let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
333        storage
334            .save(&make_entry("e1", "first", 100, kind::MANUAL))
335            .unwrap();
336        storage
337            .save(&make_entry("e2", "second", 200, kind::SNAPSHOT))
338            .unwrap();
339        storage
340            .save(&make_entry("e3", "third", 300, kind::SUMMARY))
341            .unwrap();
342
343        let top2 = storage.get_top_k(2).unwrap();
344        assert_eq!(top2.len(), 2);
345        assert_eq!(top2[0].id, "e3"); // most recent
346        assert_eq!(top2[1].id, "e2");
347    }
348
349    #[test]
350    fn test_save_and_get_all() {
351        let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
352        storage
353            .save(&make_entry("e1", "first", 100, kind::MANUAL))
354            .unwrap();
355        storage
356            .save(&make_entry("e2", "second", 200, kind::MANUAL))
357            .unwrap();
358        storage
359            .save(&make_entry("e3", "third", 300, kind::MANUAL))
360            .unwrap();
361
362        let all = storage.get_all().unwrap();
363        assert_eq!(all.len(), 3);
364        // ordered by timestamp desc
365        assert_eq!(all[0].id, "e3");
366        assert_eq!(all[1].id, "e2");
367        assert_eq!(all[2].id, "e1");
368    }
369
370    #[test]
371    fn test_delete() {
372        let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
373        storage
374            .save(&make_entry("e1", "hello", 1000, kind::MANUAL))
375            .unwrap();
376
377        assert!(storage.delete("e1").unwrap());
378        assert!(!storage.delete("nonexistent").unwrap());
379        assert_eq!(storage.count().unwrap(), 0);
380    }
381
382    #[test]
383    fn test_clear() {
384        let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
385        storage
386            .save(&make_entry("e1", "a", 100, kind::MANUAL))
387            .unwrap();
388        storage
389            .save(&make_entry("e2", "b", 200, kind::MANUAL))
390            .unwrap();
391        storage
392            .save(&make_entry("e3", "c", 300, kind::MANUAL))
393            .unwrap();
394
395        let cleared = storage.clear().unwrap();
396        assert_eq!(cleared, 3);
397        assert_eq!(storage.count().unwrap(), 0);
398    }
399
400    #[test]
401    fn test_clear_scope() {
402        let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
403        storage
404            .save(&make_scoped_entry("e1", "a", 100, "scope-a"))
405            .unwrap();
406        storage
407            .save(&make_scoped_entry("e2", "b", 200, "scope-a"))
408            .unwrap();
409        storage
410            .save(&make_scoped_entry("e3", "c", 300, "scope-b"))
411            .unwrap();
412
413        let cleared = storage.clear_scope("scope-a").unwrap();
414        assert_eq!(cleared, 2);
415        assert_eq!(storage.count().unwrap(), 1);
416
417        let all = storage.get_all().unwrap();
418        assert_eq!(all[0].id, "e3");
419    }
420
421    #[test]
422    fn test_clear_scope_no_match() {
423        let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
424        storage
425            .save(&make_scoped_entry("e1", "a", 100, "scope-a"))
426            .unwrap();
427
428        let cleared = storage.clear_scope("scope-z").unwrap();
429        assert_eq!(cleared, 0);
430        assert_eq!(storage.count().unwrap(), 1);
431    }
432
433    #[test]
434    fn test_lru_eviction() {
435        let (storage, _) = open_storage(Path::new(":memory:"), 2).unwrap();
436        storage
437            .save(&make_entry("e1", "oldest", 100, kind::MANUAL))
438            .unwrap();
439        storage
440            .save(&make_entry("e2", "middle", 200, kind::MANUAL))
441            .unwrap();
442        storage
443            .save(&make_entry("e3", "newest", 300, kind::MANUAL))
444            .unwrap();
445
446        assert_eq!(storage.count().unwrap(), 2);
447
448        let all = storage.get_all().unwrap();
449        let ids: Vec<&str> = all.iter().map(|e| e.id.as_str()).collect();
450        assert!(
451            !ids.contains(&"e1"),
452            "oldest entry should have been evicted"
453        );
454        assert!(ids.contains(&"e2"));
455        assert!(ids.contains(&"e3"));
456    }
457
458    #[test]
459    fn test_fts_search() {
460        let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
461        storage
462            .save(&make_entry(
463                "e1",
464                "rust programming language",
465                100,
466                kind::MANUAL,
467            ))
468            .unwrap();
469        storage
470            .save(&make_entry("e2", "python scripting", 200, kind::MANUAL))
471            .unwrap();
472        storage
473            .save(&make_entry("e3", "rust borrow checker", 300, kind::MANUAL))
474            .unwrap();
475
476        let results = searcher.search("rust", None, 5).unwrap();
477        assert_eq!(results.len(), 2);
478        // Assert ordering by relevance (highest score first), not absolute values.
479        assert!(
480            results[0].score >= results[1].score,
481            "results should be ordered by descending score"
482        );
483    }
484
485    #[test]
486    fn test_fts_search_no_results() {
487        let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
488        storage
489            .save(&make_entry("e1", "hello world", 100, kind::MANUAL))
490            .unwrap();
491
492        let results = searcher.search("nonexistent", None, 5).unwrap();
493        assert!(results.is_empty());
494    }
495
496    #[test]
497    fn test_fts_search_scoped() {
498        let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
499        storage
500            .save(&make_scoped_entry("e1", "rust programming", 100, "a"))
501            .unwrap();
502        storage
503            .save(&make_scoped_entry("e2", "rust borrow checker", 200, "b"))
504            .unwrap();
505
506        let results_a = searcher.search("rust", Some("a"), 5).unwrap();
507        assert_eq!(results_a.len(), 1);
508        assert_eq!(results_a[0].entry.id, "e1");
509
510        let results_b = searcher.search("rust", Some("b"), 5).unwrap();
511        assert_eq!(results_b.len(), 1);
512        assert_eq!(results_b[0].entry.id, "e2");
513
514        let results_all = searcher.search("rust", None, 5).unwrap();
515        assert_eq!(results_all.len(), 2);
516    }
517
518    #[test]
519    fn test_v2_migration_idempotent() {
520        let storage1 = SqliteStorage::open(Path::new(":memory:"), 100).unwrap();
521        let conn = storage1.pool().get().unwrap();
522        // Running migrate a second time on the same connection should succeed.
523        crate::storage::schema::migrate(&conn).unwrap();
524    }
525
526    #[test]
527    fn test_v1_to_v3_migration() {
528        let db_path = temp_db_path("v1-to-v3");
529
530        {
531            let conn = Connection::open(&db_path).unwrap();
532            conn.execute_batch(crate::storage::schema::SCHEMA_V1)
533                .unwrap();
534            conn.execute_batch(
535                "CREATE TABLE IF NOT EXISTS schema_version (id INTEGER PRIMARY KEY CHECK(id = 1), version INTEGER NOT NULL)",
536            )
537            .unwrap();
538            conn.execute(
539                "INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, 1)",
540                [],
541            )
542            .unwrap();
543
544            conn.execute(
545                "INSERT INTO entries (id, content, timestamp, kind, token_count) VALUES (?1, ?2, ?3, ?4, ?5)",
546                rusqlite::params!["m1", "manual entry", 100_i64, "Manual", 2_i64],
547            )
548            .unwrap();
549            conn.execute(
550                "INSERT INTO entries (id, content, timestamp, kind, token_count) VALUES (?1, ?2, ?3, ?4, ?5)",
551                rusqlite::params!["p1", "precompact entry", 200_i64, "PreCompact", 3_i64],
552            )
553            .unwrap();
554            conn.execute(
555                "INSERT INTO entries (id, content, timestamp, kind, token_count) VALUES (?1, ?2, ?3, ?4, ?5)",
556                rusqlite::params!["a1", "auto entry", 300_i64, "Auto", 4_i64],
557            )
558            .unwrap();
559        }
560
561        let storage = SqliteStorage::open(&db_path, 100).unwrap();
562        let conn = storage.pool().get().unwrap();
563
564        let version: i64 = conn
565            .query_row(
566                "SELECT COALESCE(MAX(version), 0) FROM schema_version",
567                [],
568                |r| r.get(0),
569            )
570            .unwrap();
571        assert_eq!(version, 3);
572
573        // Kinds are remapped to the new lowercase TEXT vocabulary.
574        let mut kinds: Vec<String> = conn
575            .prepare("SELECT kind FROM entries ORDER BY timestamp")
576            .unwrap()
577            .query_map([], |r| r.get(0))
578            .unwrap()
579            .collect::<rusqlite::Result<Vec<_>>>()
580            .unwrap();
581        kinds.sort();
582        assert_eq!(kinds, vec!["manual", "snapshot", "summary"]);
583
584        let tags: i64 = conn
585            .query_row("SELECT COUNT(*) FROM tags", [], |r| r.get(0))
586            .unwrap();
587        assert_eq!(tags, 0, "tags table should exist but be empty");
588
589        let entry_tags: i64 = conn
590            .query_row("SELECT COUNT(*) FROM entry_tags", [], |r| r.get(0))
591            .unwrap();
592        assert_eq!(entry_tags, 0, "entry_tags table should exist but be empty");
593
594        // v2-only runtime tables are gone after the v3 rebuild.
595        let runtime_configs_exists: i64 = conn
596            .query_row(
597                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='runtime_configs'",
598                [],
599                |r| r.get(0),
600            )
601            .unwrap();
602        assert_eq!(runtime_configs_exists, 0);
603
604        let _ = fs::remove_file(&db_path);
605    }
606
607    #[test]
608    fn test_v2_to_v3_migration() {
609        let db_path = temp_db_path("v2-to-v3");
610
611        {
612            let conn = Connection::open(&db_path).unwrap();
613
614            // Build a v2 fixture by running SCHEMA_V1 then SCHEMA_V2 directly,
615            // then inserting rows with runtime columns populated.
616            conn.execute_batch(crate::storage::schema::SCHEMA_V1)
617                .unwrap();
618            conn.execute_batch(
619                "CREATE TABLE IF NOT EXISTS schema_version (id INTEGER PRIMARY KEY CHECK(id = 1), version INTEGER NOT NULL)",
620            )
621            .unwrap();
622            conn.execute(
623                "INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, 1)",
624                [],
625            )
626            .unwrap();
627            conn.execute_batch(crate::storage::schema::SCHEMA_V2)
628                .unwrap();
629
630            conn.execute(
631                "INSERT INTO entries (
632                    id, content, timestamp, kind, token_count, session_id,
633                    compaction_count, compaction_trigger, runtime, model, cwd,
634                    git_branch, git_sha, turn_id, agent_type, agent_id
635                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
636                rusqlite::params![
637                    "a1",
638                    "auto entry with runtime metadata",
639                    300_i64,
640                    "Auto",
641                    4_i64,
642                    "session-abc",
643                    2_i64,
644                    "matcher:threshold",
645                    "codex",
646                    "gpt-5.3-codex",
647                    "/workspace/context-forge",
648                    "feature/schema-v2",
649                    "abc123def",
650                    "turn-77",
651                    "coder",
652                    "agent-main",
653                ],
654            )
655            .unwrap();
656            conn.execute(
657                "INSERT INTO entries (id, content, timestamp, kind, token_count) VALUES (?1, ?2, ?3, ?4, ?5)",
658                rusqlite::params!["m1", "manual entry", 100_i64, "Manual", 2_i64],
659            )
660            .unwrap();
661        }
662
663        let storage = SqliteStorage::open(&db_path, 100).unwrap();
664
665        // entries preserved
666        let all = storage.get_all().unwrap();
667        assert_eq!(all.len(), 2);
668
669        // kinds remapped: 'Auto' -> 'summary', 'Manual' -> 'manual'
670        let auto_entry = all.iter().find(|e| e.id == "a1").unwrap();
671        let manual_entry = all.iter().find(|e| e.id == "m1").unwrap();
672        assert_eq!(auto_entry.kind, "summary");
673        assert_eq!(manual_entry.kind, "manual");
674
675        // runtime fields present inside metadata JSON
676        let metadata = auto_entry
677            .metadata
678            .as_ref()
679            .expect("metadata should be present for migrated v2 entry");
680        assert_eq!(metadata["runtime"], "codex");
681        assert_eq!(metadata["model"], "gpt-5.3-codex");
682        assert_eq!(metadata["cwd"], "/workspace/context-forge");
683        assert_eq!(metadata["git_branch"], "feature/schema-v2");
684        assert_eq!(metadata["git_sha"], "abc123def");
685        assert_eq!(metadata["compaction_trigger"], "matcher:threshold");
686        assert_eq!(metadata["turn_id"], "turn-77");
687        assert_eq!(metadata["agent_type"], "coder");
688        assert_eq!(metadata["agent_id"], "agent-main");
689
690        // session_id and token_count survive the rebuild
691        assert_eq!(auto_entry.session_id.as_deref(), Some("session-abc"));
692        assert_eq!(auto_entry.token_count, Some(4));
693
694        // FTS query still matches content
695        let (_, searcher) = open_storage(&db_path, 100).unwrap();
696        let results = searcher.search("runtime metadata", None, 10).unwrap();
697        assert!(
698            results.iter().any(|r| r.entry.id == "a1"),
699            "FTS search should still find the migrated entry's content"
700        );
701
702        // runtime_configs table is gone
703        let conn = storage.pool().get().unwrap();
704        let runtime_configs_exists: i64 = conn
705            .query_row(
706                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='runtime_configs'",
707                [],
708                |r| r.get(0),
709            )
710            .unwrap();
711        assert_eq!(runtime_configs_exists, 0);
712
713        let version: i64 = conn
714            .query_row(
715                "SELECT COALESCE(MAX(version), 0) FROM schema_version",
716                [],
717                |r| r.get(0),
718            )
719            .unwrap();
720        assert_eq!(version, 3);
721
722        let _ = fs::remove_file(&db_path);
723    }
724
725    #[test]
726    fn test_new_entry_with_scope_and_metadata() {
727        let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
728
729        let metadata = serde_json::json!({"runtime": "codex", "model": "gpt-5.3-codex"});
730        let entry = ContextEntry {
731            id: "v3-entry".into(),
732            content: "entry with scope and metadata".into(),
733            timestamp: 1_700_000_100,
734            kind: kind::SUMMARY.to_owned(),
735            scope: Some("project:homelab-rs".into()),
736            session_id: Some("session-123".into()),
737            token_count: Some(6),
738            metadata: Some(metadata.clone()),
739        };
740
741        storage.save(&entry).unwrap();
742
743        let all = storage.get_all().unwrap();
744        assert_eq!(all.len(), 1);
745        let got = &all[0];
746        assert_eq!(got.id, entry.id);
747        assert_eq!(got.content, entry.content);
748        assert_eq!(got.timestamp, entry.timestamp);
749        assert_eq!(got.kind, entry.kind);
750        assert_eq!(got.scope, entry.scope);
751        assert_eq!(got.session_id, entry.session_id);
752        assert_eq!(got.token_count, entry.token_count);
753        assert_eq!(got.metadata, Some(metadata));
754    }
755
756    #[test]
757    fn test_insert_or_replace() {
758        let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
759        storage
760            .save(&make_entry("e1", "original content", 100, kind::MANUAL))
761            .unwrap();
762        storage
763            .save(&make_entry("e1", "updated content", 200, kind::SUMMARY))
764            .unwrap();
765
766        assert_eq!(storage.count().unwrap(), 1);
767
768        let all = storage.get_all().unwrap();
769        assert_eq!(all[0].content, "updated content");
770    }
771
772    #[test]
773    fn test_search_match_all_query() {
774        let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
775        storage
776            .save(&make_entry("e1", "first entry", 100, kind::MANUAL))
777            .unwrap();
778        storage
779            .save(&make_entry("e2", "second entry", 200, kind::SNAPSHOT))
780            .unwrap();
781        storage
782            .save(&make_entry("e3", "third entry", 300, kind::SUMMARY))
783            .unwrap();
784
785        let results = searcher.search(MATCH_ALL_QUERY, None, 10).unwrap();
786        assert_eq!(results.len(), 3);
787
788        // Ordered by timestamp descending (newest first).
789        assert_eq!(results[0].entry.id, "e3");
790        assert_eq!(results[1].entry.id, "e2");
791        assert_eq!(results[2].entry.id, "e1");
792
793        // Match-all results all share the same fixed score.
794        for r in &results {
795            assert!((r.score - 1.0).abs() < f64::EPSILON);
796        }
797    }
798
799    #[test]
800    fn test_search_match_all_query_scoped() {
801        let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
802        storage
803            .save(&make_scoped_entry("e1", "first entry", 100, "a"))
804            .unwrap();
805        storage
806            .save(&make_scoped_entry("e2", "second entry", 200, "b"))
807            .unwrap();
808
809        let results_a = searcher.search(MATCH_ALL_QUERY, Some("a"), 10).unwrap();
810        assert_eq!(results_a.len(), 1);
811        assert_eq!(results_a[0].entry.id, "e1");
812
813        let results_all = searcher.search(MATCH_ALL_QUERY, None, 10).unwrap();
814        assert_eq!(results_all.len(), 2);
815    }
816
817    #[test]
818    fn search_with_fts5_operator_characters_does_not_error() {
819        let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
820        storage
821            .save(&make_entry("e1", "marco polo", 100, kind::MANUAL))
822            .unwrap();
823
824        // Production bug: a query containing `"` and `.` previously caused
825        // `fts5: syntax error`. It must now succeed and find the entry.
826        let results = searcher.search(r#"if I say "marco"."#, None, 10).unwrap();
827
828        assert!(
829            results.iter().any(|r| r.entry.id == "e1"),
830            "expected 'marco polo' entry to be found despite FTS5 operator characters in the query"
831        );
832    }
833
834    #[test]
835    fn search_or_joins_terms_for_message_length_queries() {
836        let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
837        storage
838            .save(&make_entry("e1", "alpha one", 100, kind::MANUAL))
839            .unwrap();
840        storage
841            .save(&make_entry("e2", "beta two", 200, kind::MANUAL))
842            .unwrap();
843
844        // Under implicit AND, no entry contains all of "alpha", "beta", and
845        // "gamma", so this would return zero results. OR-join must return both.
846        let results = searcher.search("alpha beta gamma", None, 10).unwrap();
847
848        let ids: Vec<&str> = results.iter().map(|r| r.entry.id.as_str()).collect();
849        assert!(ids.contains(&"e1"), "expected 'alpha one' entry in results");
850        assert!(ids.contains(&"e2"), "expected 'beta two' entry in results");
851    }
852}