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